thincoder 0.12.62 → 0.12.64

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 (231) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +46 -49
  3. package/bin/thincoder.mjs +68 -34
  4. package/package.json +7 -6
  5. package/src/acp/bridge.mjs +35 -15
  6. package/src/acp/client-caps.mjs +86 -0
  7. package/src/acp/ext.mjs +86 -0
  8. package/src/acp/handlers-session.mjs +257 -0
  9. package/src/acp/handlers-slots.mjs +196 -0
  10. package/src/acp/login.mjs +48 -0
  11. package/src/acp/session.mjs +6 -4
  12. package/src/acp.mjs +67 -379
  13. package/src/cli/distill-command.mjs +3 -3
  14. package/src/cli/make-agent.mjs +60 -17
  15. package/src/cli/memory-command.mjs +3 -3
  16. package/src/cli/permission.mjs +4 -48
  17. package/src/cli/setup-wizard.mjs +1 -1
  18. package/src/completions.mjs +3 -1
  19. package/src/crash-reports.mjs +1 -1
  20. package/src/distill.mjs +4 -4
  21. package/src/heap-watch.mjs +1 -1
  22. package/src/prompt-injections.mjs +20 -0
  23. package/src/tui/agent-turn.mjs +40 -9
  24. package/src/tui/cmd-advisor.mjs +5 -5
  25. package/src/tui/cmd-config.mjs +8 -8
  26. package/src/tui/cmd-eng.mjs +35 -9
  27. package/src/tui/cmd-mcp.mjs +9 -8
  28. package/src/tui/cmd-model.mjs +1 -1
  29. package/src/tui/cmd-new.mjs +6 -5
  30. package/src/tui/cmd-plan.mjs +9 -0
  31. package/src/tui/cmd-reindex.mjs +1 -1
  32. package/src/tui/cmd-restore.mjs +2 -2
  33. package/src/tui/cmd-session.mjs +24 -1
  34. package/src/tui/cmd-skills.mjs +1 -1
  35. package/src/tui/cmd-think.mjs +22 -9
  36. package/src/tui/config-helpers.mjs +1 -1
  37. package/src/tui/display-budget.mjs +33 -11
  38. package/src/tui/index.mjs +18 -10
  39. package/src/tui/interaction.mjs +16 -7
  40. package/src/tui/key-modes.mjs +9 -4
  41. package/src/tui/ledger-surface.mjs +22 -59
  42. package/src/tui/model-catalog.mjs +4 -4
  43. package/src/tui/model-picker.mjs +8 -7
  44. package/src/tui/mouse.mjs +11 -6
  45. package/src/tui/pickers.mjs +15 -2
  46. package/src/tui/render-conversation.mjs +1 -1
  47. package/src/tui/render-frame.mjs +15 -6
  48. package/src/tui/render-loop.mjs +1 -1
  49. package/src/tui/render-segments.mjs +3 -1
  50. package/src/tui/slash-commands.mjs +1 -1
  51. package/src/tui/startup.mjs +14 -14
  52. package/src/tui/subagent-blocks.mjs +20 -3
  53. package/src/tui/subagent-freeze.mjs +73 -2
  54. package/src/tui/suspension-drive.mjs +57 -23
  55. package/src/tui/tool-events.mjs +11 -8
  56. package/src/tui/tui-lifecycle.mjs +9 -0
  57. package/src/tui/wizard.mjs +3 -3
  58. package/src/tui/wrapped-spawn.mjs +6 -2
  59. package/src/abort-provenance.mjs +0 -116
  60. package/src/advisor/citations.mjs +0 -139
  61. package/src/advisor/compaction.mjs +0 -174
  62. package/src/advisor/convergence.mjs +0 -80
  63. package/src/advisor/history.mjs +0 -77
  64. package/src/advisor/loop.mjs +0 -293
  65. package/src/advisor/messages.mjs +0 -299
  66. package/src/advisor/project-context.mjs +0 -194
  67. package/src/advisor/repos.mjs +0 -150
  68. package/src/advisor/run.mjs +0 -293
  69. package/src/advisor/truncate.mjs +0 -57
  70. package/src/advisor.mjs +0 -290
  71. package/src/agent/completion.mjs +0 -146
  72. package/src/agent/dispatch.mjs +0 -489
  73. package/src/agent/helpers.mjs +0 -384
  74. package/src/agent/post-turn.mjs +0 -70
  75. package/src/agent/record-results.mjs +0 -174
  76. package/src/agent/relay-prefix.mjs +0 -39
  77. package/src/agent/run-stages.mjs +0 -244
  78. package/src/agent/setup-reminders.mjs +0 -69
  79. package/src/agent/setup.mjs +0 -354
  80. package/src/agent/spawn-child.mjs +0 -243
  81. package/src/agent-tools/advisor-async.mjs +0 -346
  82. package/src/agent-tools/advisor-settle.mjs +0 -231
  83. package/src/agent-tools/advisor.mjs +0 -260
  84. package/src/agent-tools/async-settle.mjs +0 -204
  85. package/src/agent-tools/batch-segment.mjs +0 -195
  86. package/src/agent-tools/consult.mjs +0 -473
  87. package/src/agent-tools/design-token.mjs +0 -117
  88. package/src/agent-tools/digest-budget.mjs +0 -76
  89. package/src/agent-tools/eng.mjs +0 -67
  90. package/src/agent-tools/escalate-async.mjs +0 -295
  91. package/src/agent-tools/goal.mjs +0 -119
  92. package/src/agent-tools/plan.mjs +0 -81
  93. package/src/agent-tools/read-history.mjs +0 -309
  94. package/src/agent-tools/recent-changes.mjs +0 -24
  95. package/src/agent-tools/review-streak.mjs +0 -93
  96. package/src/agent-tools/settings.mjs +0 -265
  97. package/src/agent-tools/skill.mjs +0 -47
  98. package/src/agent-tools/subagent-actions.mjs +0 -482
  99. package/src/agent-tools/subagent-async.mjs +0 -434
  100. package/src/agent-tools/subagent-panel.mjs +0 -160
  101. package/src/agent-tools/subagent-run.mjs +0 -205
  102. package/src/agent-tools/subagent-scheduler.mjs +0 -392
  103. package/src/agent-tools/subagent-spawn.mjs +0 -459
  104. package/src/agent-tools/subagent.mjs +0 -404
  105. package/src/agent-tools/task.mjs +0 -87
  106. package/src/agent-tools/timer.mjs +0 -46
  107. package/src/agent-tools/verify.mjs +0 -271
  108. package/src/agent-tools.mjs +0 -17
  109. package/src/agent.mjs +0 -417
  110. package/src/auto-think.mjs +0 -115
  111. package/src/config-migrate.mjs +0 -70
  112. package/src/config.mjs +0 -496
  113. package/src/context.mjs +0 -392
  114. package/src/conventions.mjs +0 -223
  115. package/src/embedding.mjs +0 -120
  116. package/src/escape.mjs +0 -152
  117. package/src/expand-home.mjs +0 -16
  118. package/src/explore-distill.mjs +0 -155
  119. package/src/generate-title.mjs +0 -88
  120. package/src/git/checkpoint.mjs +0 -448
  121. package/src/git/gitmem.mjs +0 -100
  122. package/src/hooks.mjs +0 -97
  123. package/src/ledger.mjs +0 -227
  124. package/src/log.mjs +0 -195
  125. package/src/markdown.mjs +0 -106
  126. package/src/mcp/helpers.mjs +0 -51
  127. package/src/mcp/transport-http.mjs +0 -248
  128. package/src/mcp/transport-stdio.mjs +0 -140
  129. package/src/mcp/transport-ws.mjs +0 -122
  130. package/src/mcp.mjs +0 -295
  131. package/src/memory/code-index.mjs +0 -219
  132. package/src/memory/code-sync.mjs +0 -415
  133. package/src/memory/core.mjs +0 -299
  134. package/src/memory/delete.mjs +0 -236
  135. package/src/memory/docs.mjs +0 -419
  136. package/src/memory/file-walk.mjs +0 -109
  137. package/src/memory/scan.mjs +0 -95
  138. package/src/memory/schema.mjs +0 -452
  139. package/src/memory.mjs +0 -21
  140. package/src/model-ref.mjs +0 -66
  141. package/src/model-specs.mjs +0 -179
  142. package/src/peer-domains.mjs +0 -265
  143. package/src/peer-instances.mjs +0 -231
  144. package/src/prompt-overlays.mjs +0 -82
  145. package/src/prompts/advisor-design.md +0 -41
  146. package/src/prompts/advisor-round1.md +0 -41
  147. package/src/prompts/advisor-round2.md +0 -46
  148. package/src/prompts/advisor-round3.md +0 -42
  149. package/src/prompts/common.md +0 -115
  150. package/src/prompts/consult-base.md +0 -19
  151. package/src/prompts/discipline-engineering.md +0 -258
  152. package/src/prompts/discipline-normal.md +0 -185
  153. package/src/prompts/persona-coder.md +0 -21
  154. package/src/prompts/persona-eng-coder.md +0 -37
  155. package/src/prompts/persona-eng-designer.md +0 -60
  156. package/src/prompts/persona-engineering.md +0 -55
  157. package/src/prompts/persona-explore.md +0 -15
  158. package/src/prompts/persona-normal.md +0 -27
  159. package/src/prompts/persona-plan.md +0 -26
  160. package/src/provider/anthropic.mjs +0 -225
  161. package/src/provider/core.mjs +0 -476
  162. package/src/provider/errors.mjs +0 -101
  163. package/src/provider/google.mjs +0 -257
  164. package/src/provider/index.mjs +0 -7
  165. package/src/provider/list-models.mjs +0 -93
  166. package/src/provider/normalize.mjs +0 -81
  167. package/src/provider/rate.mjs +0 -108
  168. package/src/provider/responses.mjs +0 -495
  169. package/src/provider/retry.mjs +0 -88
  170. package/src/provider/sse.mjs +0 -264
  171. package/src/proxy.mjs +0 -261
  172. package/src/rules.mjs +0 -53
  173. package/src/session-gc.mjs +0 -221
  174. package/src/session-guard.mjs +0 -59
  175. package/src/session-migrate.mjs +0 -48
  176. package/src/session-rename.mjs +0 -38
  177. package/src/session-segments.mjs +0 -100
  178. package/src/session-slots.mjs +0 -492
  179. package/src/session-store.mjs +0 -441
  180. package/src/session.mjs +0 -492
  181. package/src/skills.mjs +0 -153
  182. package/src/text-budget.mjs +0 -46
  183. package/src/token-ttl.mjs +0 -274
  184. package/src/tools/apply_patch.md +0 -15
  185. package/src/tools/bash.md +0 -37
  186. package/src/tools/bash.mjs +0 -268
  187. package/src/tools/checklist-sync.mjs +0 -181
  188. package/src/tools/checklist.md +0 -13
  189. package/src/tools/checklist.mjs +0 -299
  190. package/src/tools/delete.md +0 -13
  191. package/src/tools/edit-batch.mjs +0 -191
  192. package/src/tools/edit-diff.mjs +0 -348
  193. package/src/tools/edit.md +0 -30
  194. package/src/tools/execute.md +0 -21
  195. package/src/tools/execute.mjs +0 -228
  196. package/src/tools/fetch.md +0 -12
  197. package/src/tools/file.mjs +0 -469
  198. package/src/tools/file_ops.md +0 -17
  199. package/src/tools/get_current_time.md +0 -8
  200. package/src/tools/git-checkpoint.mjs +0 -143
  201. package/src/tools/git-ext.mjs +0 -173
  202. package/src/tools/git.md +0 -54
  203. package/src/tools/git.mjs +0 -356
  204. package/src/tools/glob-dialect.mjs +0 -130
  205. package/src/tools/glob.md +0 -11
  206. package/src/tools/grep.md +0 -19
  207. package/src/tools/hashline_edit.md +0 -14
  208. package/src/tools/index.mjs +0 -36
  209. package/src/tools/insert_after.md +0 -15
  210. package/src/tools/lint.md +0 -10
  211. package/src/tools/linter.mjs +0 -128
  212. package/src/tools/ls.md +0 -12
  213. package/src/tools/lsp.md +0 -10
  214. package/src/tools/lsp.mjs +0 -316
  215. package/src/tools/ops.mjs +0 -299
  216. package/src/tools/patch.mjs +0 -282
  217. package/src/tools/process.md +0 -10
  218. package/src/tools/question.md +0 -16
  219. package/src/tools/question.mjs +0 -26
  220. package/src/tools/read.md +0 -20
  221. package/src/tools/read_image.md +0 -8
  222. package/src/tools/repomap.mjs +0 -314
  223. package/src/tools/search.mjs +0 -236
  224. package/src/tools/shared.mjs +0 -446
  225. package/src/tools/tree.md +0 -14
  226. package/src/tools/tree.mjs +0 -66
  227. package/src/tools/wait_for.md +0 -22
  228. package/src/tools/web.mjs +0 -224
  229. package/src/tools/websearch.md +0 -16
  230. package/src/tools/write.md +0 -11
  231. package/src/traces/trace-store.mjs +0 -355
@@ -0,0 +1,48 @@
1
+ /**
2
+ * login.mjs — `thincoder acp --login` (ACP-CLIENT.md §11.2 改法 4): the terminal
3
+ * authentication flow advertised through `initialize.authMethods[].args = ["--login"]`
4
+ * (the client APPENDS these args to its configured `thincoder acp` invocation).
5
+ *
6
+ * Exit status semantics (schema `AuthMethodTerminal` 逐字): "A zero exit status signals
7
+ * success; any other termination signals failure." — never the stdio service mode, and
8
+ * never a silent hang on a non-interactive stdin (non-TTY ⇒ one actionable line + exit 1).
9
+ */
10
+ import { configPath, loadConfig, writeConfigAtomic } from "@thincoder/core/config.mjs"
11
+ import { setupWizard } from "../cli/setup-wizard.mjs"
12
+
13
+ /** Run the login flow. @returns {Promise<number>} process exit code (0 = success). */
14
+ export async function runAcpLogin() {
15
+ if (!process.stdin.isTTY) {
16
+ console.error(`[acp] --login needs an interactive terminal (stdin is not a TTY) — run "thincoder acp --login" in a terminal, or hand-write providers[].apiKey + defaultModel in ${configPath}`)
17
+ return 1
18
+ }
19
+ const provider = await setupWizard()
20
+ if (!provider) {
21
+ console.error(`[acp] login cancelled — no provider configured. Re-run "thincoder acp --login", or edit ${configPath}.`)
22
+ return 1
23
+ }
24
+ // 校验并补齐 defaultModel(§11.5 判据 = `loadConfig().provider` 可解析)。wizard 已写
25
+ // `raw.defaultModel = "<name>:<model>"`;此处为兜底核对——仍不可解析 ⇒ 按刚配置的渠道补齐。
26
+ let cfg = loadConfig()
27
+ if (!cfg.provider?.apiKey?.trim()) {
28
+ // writeConfigAtomic 在配置不可解析时**抛错**(config-io.mjs「refusing to overwrite」)——
29
+ // 包住并复用同一失败文案(不落 bin 顶层 rejection 通道)。
30
+ let r
31
+ try {
32
+ r = writeConfigAtomic(configPath, (raw) => { raw.defaultModel = `${provider.name}:${provider.model}` })
33
+ } catch (e) {
34
+ r = { ok: false, reason: e?.message ?? String(e) }
35
+ }
36
+ if (!r.ok) {
37
+ console.error(`[acp] login incomplete — ${r.reason}: retry "thincoder acp --login" (or edit ${configPath}).`)
38
+ return 1
39
+ }
40
+ cfg = loadConfig()
41
+ }
42
+ if (!cfg.provider?.apiKey?.trim()) {
43
+ console.error(`[acp] login incomplete — credentials still unresolvable: ${cfg.providerInvalidReason ?? "unknown reason"} (fix via /config → 默认模型, or edit ${configPath}).`)
44
+ return 1
45
+ }
46
+ console.error(`[acp] login OK — credentials at ${configPath} are ready; ACP clients can now drive sessions without an interactive terminal.`)
47
+ return 0
48
+ }
@@ -10,14 +10,16 @@
10
10
  * - The controller is rebuilt after every turn, so one cancel only affects the
11
11
  * in-flight turn; the next queued prompt starts with a clean signal.
12
12
  * - `run` is injectable for tests (defaults to the real runAgent).
13
+ * - `clientCaps` = the §3.4 client-capability snapshot taken at `session/new` time
14
+ * (read-only for the session's whole lifetime) — consumed by the fs gate (§11.4).
13
15
  */
14
- import { runAgent } from "../agent.mjs"
15
- import { saveSession } from "../session.mjs"
16
+ import { runAgent } from "@thincoder/core/agent.mjs"
17
+ import { saveSession } from "@thincoder/core/session.mjs"
16
18
  import { buildAcpCallbacks } from "./bridge.mjs"
17
19
 
18
- export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent, save = saveSession }) {
20
+ export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent, save = saveSession, clientCaps = {} }) {
19
21
  let controller = new AbortController()
20
- const callbacks = buildAcpCallbacks({ sessionId: id, notify, request, log })
22
+ const callbacks = buildAcpCallbacks({ sessionId: id, notify, request, log, clientCaps })
21
23
  let queue = Promise.resolve()
22
24
  let busy = false
23
25
 
package/src/acp.mjs CHANGED
@@ -3,66 +3,39 @@
3
3
  * Agent Client Protocol (schema v1) on stdio, so ACP clients (Zed, JetBrains
4
4
  * AI Chat, Paseo) can drive sessions directly.
5
5
  *
6
- * M1 scope (see docs/design/ACP-CLIENT.md §9):
7
- * initialize / authenticate / session/new / session/prompt / session/cancel / session/close
8
- * M2: tools + request_permission + fs reverse-RPC. M3: session load/resume/list/delete + config options.
6
+ * Handler bodies live in four modules (division + criteria = docs/cli/design/ACP-CLIENT.md
7
+ * §3.5 — the former 368-line single-function `buildAcpHandlers` split by method family):
8
+ * acp/client-caps.mjs initialize / authenticate(版本协商 · 能力快照 · authMethods 构造)
9
+ * acp/handlers-session.mjs session/{new,prompt,cancel,close,set_mode,set_config_option} + 门助手
10
+ * acp/handlers-slots.mjs session/{list,load,resume,delete}(持久化槽族)
11
+ * acp/ext.mjs checkpoint/* · memory/*
12
+ * acp/login.mjs `--login` terminal auth flow (non-stdio)
13
+ * This file keeps constants, the injectable defaults and the assembly — the seam
14
+ * `buildAcpHandlers(deps)` (signature + `{ handlers, sessions, notifyRef, requestRef }`)
15
+ * is the only external contract (both existing ACP test files drive it directly).
16
+ *
17
+ * #41(2026-09-18):`session/load` / `session/resume` 两路工程模式会话改「先判后装载」
18
+ * (判据 = 入口决策树单源;拒 ⇒ 既有错误通道、不建会话)+ 装载后一行重估(附着)——
19
+ * docs/core/design/MANIFEST.md §2.8 F3/F4。
9
20
  *
10
21
  * Auth: reuse the terminal config (~/.thincoder/config.json) — a resolvable
11
22
  * provider API key means "configured". No account system; `logout` is absent.
12
23
  *
13
- * `isConfigured` / `createSession` are injectable for tests.
24
+ * `isConfigured` / `providerStatus` / `createSession` are injectable for tests.
14
25
  */
15
26
  import { readFileSync } from "node:fs"
16
- import { resolve, join } from "node:path"
17
- import { loadConfig, configDir } from "./config.mjs"
27
+ import { loadConfig } from "@thincoder/core/config.mjs"
18
28
  import { assembleAgent } from "./cli/make-agent.mjs"
19
- import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
29
+ import { createAcpServer } from "./acp/transport.mjs"
20
30
  import { createAcpSession } from "./acp/session.mjs"
21
- import { replayHistory } from "./acp/bridge.mjs"
22
- import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession, slotPath, bindRecordStore } from "./session.mjs"
23
- import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "./git/checkpoint.mjs"
24
- import { createMemory, list as memList, remove as memRemove } from "./memory.mjs"
31
+ import { createCapsHandlers } from "./acp/client-caps.mjs"
32
+ import { createRequireConfigured, createSessionHandlers } from "./acp/handlers-session.mjs"
33
+ import { createSlotsHandlers } from "./acp/handlers-slots.mjs"
34
+ import { createExtHandlers } from "./acp/ext.mjs"
25
35
 
26
36
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
27
37
 
28
- /** Load a specific slot file (not the active one) — session/load by id.
29
- * 2026-08-31 会诊 deepseek 🟡:改用 session.mjs 共享 loadSlotFile(校验/保现场/.tmp
30
- * 回退与主路径一致)——本地实现此前无 .unreadable/.corrupted 保留。 */
31
-
32
- /**
33
- * Apply a session-level config option to the agent instance (memory only —
34
- * the session's own runtime state, last-write-wins; not persisted to config.json).
35
- * Returns true when the configId is known.
36
- */
37
- function applyConfigOption(agent, configId, value) {
38
- switch (configId) {
39
- case "model": {
40
- if (typeof value !== "string" || !value.trim()) return false
41
- if (!agent.provider) return false // nothing to configure
42
- // Split on the FIRST colon only — model names may contain colons.
43
- const ci = value.indexOf(":")
44
- const provider = ci >= 0 ? value.slice(0, ci) : null
45
- const model = (ci >= 0 ? value.slice(ci + 1) : value).trim()
46
- if (provider && provider !== agent.provider.name) agent.provider.name = provider
47
- agent.provider.model = model
48
- return true
49
- }
50
- case "thinking": {
51
- if (typeof value !== "boolean" || !agent.provider) return false
52
- agent.provider.thinking = value ? { type: "enabled" } : { type: "disabled" }
53
- return true
54
- }
55
- case "mode": {
56
- if (value !== "plan" && value !== "normal") return false
57
- agent.planMode = value === "plan"
58
- return true
59
- }
60
- default:
61
- return false
62
- }
63
- }
64
-
65
- /** Config is "configured" when the active provider has a resolvable API key (env fallback included). */
38
+ /** Config is "configured" when the active provider has a resolvable API key (providers[].apiKey only). */
66
39
  export function defaultIsConfigured() {
67
40
  try {
68
41
  return !!loadConfig().provider?.apiKey?.trim()
@@ -71,6 +44,23 @@ export function defaultIsConfigured() {
71
44
  }
72
45
  }
73
46
 
47
+ /** 姐妹探针(§11.5「判据拆分」· 评审 #1):与 `defaultIsConfigured` 同源同式,另区分
48
+ * 「无 key」与「key 在位但 defaultModel 不可解析」——`keyPresent` **不经**
49
+ * `resolveRuntimeProvider`(defaultModel 不可解析时仍为真);`reason` 复用 config 单源文案
50
+ * `providerInvalidReason`(defaultModel 未设时逐字含 "defaultModel"),不新增文案。 */
51
+ export function defaultProviderStatus() {
52
+ try {
53
+ const cfg = loadConfig()
54
+ return {
55
+ ok: !!cfg.provider?.apiKey?.trim(),
56
+ keyPresent: Array.isArray(cfg.providers) && cfg.providers.some((p) => !!p?.apiKey?.trim()),
57
+ reason: cfg.providerInvalidReason ?? null,
58
+ }
59
+ } catch {
60
+ return { ok: false, keyPresent: false, reason: null }
61
+ }
62
+ }
63
+
74
64
  /** 第 27 批 §12.3⑤(R-A1.1):ACP 会话装配期剔除的工具。ACP 是 headless 通道——
75
65
  * question 工具需要交互 UI(`src/tools/question.mjs` 无 `ctx.onQuestion` 即 throw),
76
66
  * 模型可见即会调用、调用必错 → 装配期剔除(schema 直接少一条)。 */
@@ -79,348 +69,56 @@ export const ACP_EXCLUDED_TOOLS = ["question"]
79
69
  /** M1/M2 session factory: one agent per session, built from the process cwd (single-cwd model).
80
70
  * `id` is the ACP session id — it is baked into the callbacks at construction time
81
71
  * (buildAcpCallbacks closure), so it must be known BEFORE createAcpSession runs.
82
- * `request` is the transport's reverse-RPC channel (permissions + fs routing). */
83
- export async function defaultCreateSession({ id, notify, request, log }) {
72
+ * `request` is the transport's reverse-RPC channel (permissions + fs routing).
73
+ * `clientCaps` = the §3.4 snapshot taken at `session/new` time (fs gate, §11.4). */
74
+ export async function defaultCreateSession({ id, notify, request, log, clientCaps }) {
84
75
  const agent = await assembleAgent({ excludeTools: ACP_EXCLUDED_TOOLS })
85
- return createAcpSession({ id, agent, notify, request, log })
76
+ return createAcpSession({ id, agent, notify, request, log, clientCaps })
86
77
  }
87
78
 
88
79
  /**
89
- * Build the ACP method handlers. Returns { handlers, sessions, notifyRef } —
80
+ * Build the ACP method handlers. Returns { handlers, sessions, notifyRef, requestRef } —
90
81
  * notifyRef.current is set by runAcpServer once the transport exists; sessions
91
82
  * are created lazily (session/new), by which time the reference is live.
92
83
  * @param {{ version?: string, notify?: (method, params) => void, log?: (s: string) => void,
93
- * isConfigured?: () => boolean, createSession?: (ctx) => Promise<object> }} deps
84
+ * isConfigured?: () => boolean, providerStatus?: () => { ok: boolean, keyPresent: boolean, reason: string|null },
85
+ * createSession?: (ctx) => Promise<object> }} deps
94
86
  */
95
87
  export function buildAcpHandlers({
96
88
  version = VERSION,
97
89
  notify = () => {},
98
90
  log = () => {},
99
91
  isConfigured = defaultIsConfigured,
92
+ providerStatus = defaultProviderStatus,
100
93
  createSession = defaultCreateSession,
101
94
  cwd = () => process.cwd(),
102
95
  }) {
103
- const getCwd = cwd
104
96
  const notifyRef = { current: notify }
105
97
  const requestRef = { current: async () => { throw new Error("no request channel") } }
106
98
  const sessions = new Map()
107
99
  let nextId = 1
108
- let authenticated = false
109
- const findSession = (params) => {
110
- const s = sessions.get(String(params?.sessionId))
111
- if (!s) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown session ${params?.sessionId}` } }
112
- return { session: s }
100
+ /** §3.4 客户端能力快照:`initialize` 单次写入,之后只读(快照丢失 ⇒ 全取 false ⇒ 本地兜底)。 */
101
+ const clientCaps = { current: {} }
102
+ /** 共享态经单一 `ctx` 传入(§3.5)——id 分配器与槽族共享,命名空间不因拆分而裂。 */
103
+ const ctx = {
104
+ getCwd: cwd,
105
+ sessions,
106
+ allocSessionId: () => String(nextId++),
107
+ notifyRef,
108
+ requestRef,
109
+ // 能力位按「session/new 时刻取值」注入每个会话(§11.4——会话生命周期内不变)
110
+ createSession: (opts) => createSession({ ...opts, clientCaps: clientCaps.current }),
111
+ // 门助手由入口建一次、按引用传(§11.5——`session/new` 是门链首个触点)
112
+ requireConfigured: createRequireConfigured({ isConfigured, providerStatus }),
113
+ log,
113
114
  }
114
115
 
115
116
  return {
116
117
  handlers: {
117
- initialize: () => ({
118
- protocolVersion: 1,
119
- agentInfo: { name: "thincoder", version },
120
- authMethods: ["terminal"],
121
- capabilities: { fs: { read: true, write: true }, terminal: false },
122
- }),
123
-
124
- authenticate: () => {
125
- if (!isConfigured()) return { error: ACP_ERRORS.AUTH_REQUIRED }
126
- authenticated = true
127
- return { authenticated: true }
128
- },
129
-
130
- "session/new": async (params) => {
131
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
132
- if (params.cwd !== undefined && typeof params.cwd !== "string") {
133
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "cwd must be a string" } }
134
- }
135
- // Normalized comparison: resolve() collapses trailing slashes, and
136
- // normalizeCwd().toLowerCase() makes the check case-insensitive on
137
- // Windows (drive letter + path — a client sending "c:\users\…" vs
138
- // process.cwd() "C:\Users\…" must match). The ternary guards against
139
- // resolve(undefined) — it would coerce undefined to the literal
140
- // "undefined" and resolve a nonsense path. Note: `requested` never
141
- // feeds any path operation — the agent always runs in getCwd() — so
142
- // a case-insensitive match on case-sensitive platforms is harmless.
143
- const norm = (p) => normalizeCwd(p).toLowerCase()
144
- const requested = params.cwd ? resolve(params.cwd) : getCwd()
145
- if (norm(requested) !== norm(getCwd())) {
146
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `v1: cwd must equal the process working directory (${getCwd()})` } }
147
- }
148
- if (params.mcpServers?.length) {
149
- log(`[acp] MCP forwarding is M2 scope — ignoring ${params.mcpServers.length} server(s)`)
150
- }
151
- try {
152
- const id = String(nextId++)
153
- const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
154
- // `id` is immutable after construction (baked into the callbacks) — never reassign.
155
- // 2026-09-01 会诊 kimi/glm 🔴:立即认领独立槽(对齐 cmd-new)——否则首回合保存
156
- // 走 _slot ??= activeSlot() → ensureActive 早退分支(slotSessions[active]===
157
- // mySessionId 同进程恒真)→ 第二个会话拿到与第一个相同的槽号 → 双写同槽
158
- // F2 互旋。getSessionId() 是进程级,_slot 是 agent 级——粒度错配必须在此切断。
159
- session.agent._slot = newSession(getCwd())
160
- // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):新建槽绑定记录存储(baseHistory 空——
161
- // identity 待固化)——与 /new 同语义
162
- bindRecordStore(session.agent, { slotFile: slotPath(getCwd(), session.agent._slot), identity: session.agent._sessionStart ?? null, baseHistory: [] })
163
- sessions.set(id, session)
164
- return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
165
- } catch (e) {
166
- return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to create session: ${e.message}` } }
167
- }
168
- },
169
-
170
- "session/prompt": async (params) => {
171
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
172
- const found = findSession(params)
173
- if (found.error) return found
174
- const blocks = Array.isArray(params?.content) ? params.content : []
175
- const text = blocks.find((b) => b?.type === "text")?.text ?? ""
176
- if (!text) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "prompt requires a text content block" } }
177
- try {
178
- await found.session.run(text)
179
- return { stopReason: "end_turn" }
180
- } catch (e) {
181
- // Cancelled/interrupted turns are not errors on the wire.
182
- if (e?.name === "AbortError" || e?.code === "ABORT_ERR") return { stopReason: "cancelled" }
183
- return { error: { code: ACP_ERRORS.INTERNAL.code, message: e?.message ?? String(e) } }
184
- }
185
- },
186
-
187
- "session/cancel": (params) => {
188
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED } // 2026-08-31 advisor round2 🔵:与其他 handler 一致
189
- const found = findSession(params)
190
- if (found.error) return found
191
- found.session.cancel()
192
- return {}
193
- },
194
-
195
- "session/close": (params) => {
196
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED } // 2026-08-31 advisor round2 🔵:与其他 handler 一致
197
- const found = findSession(params)
198
- if (!found.error) {
199
- // Abort any in-flight turn first — the client is gone, the agent must
200
- // stop consuming LLM tokens and emitting notifications.
201
- found.session.cancel()
202
- sessions.delete(String(params.sessionId))
203
- log(`session ${params.sessionId} closed by client`)
204
- }
205
- return {}
206
- },
207
-
208
- // ─── M3: persisted slots (thincoder session archive) + config options ───
209
-
210
- "session/list": () => {
211
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
212
- const slots = listSlots(getCwd())
213
- return {
214
- sessions: slots.map((s) => ({
215
- id: String(s.slot),
216
- cwd: getCwd(), // single-cwd model (design §4.5)
217
- updatedAt: s.updatedAt ?? 0,
218
- title: s.title ?? "",
219
- messageCount: s.messageCount ?? 0,
220
- })),
221
- }
222
- },
223
-
224
- "session/load": async (params) => {
225
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
226
- const slot = Number(params.sessionId)
227
- if (!Number.isInteger(slot) || slot < 1) {
228
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
229
- }
230
- const data = loadSlotFile(getCwd(), slot)
231
- if (!data) {
232
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
233
- }
234
- try {
235
- const id = String(nextId++)
236
- const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
237
- // 2026-08-31 advisor round2 🟡:钉 _slot 前查活主——目标槽被另一活进程(CLI/另一
238
- // IDE)占用时不得钉回(双方 sessionStart 一致 → F2 永不轮转 → 同槽 last-write-wins
239
- // 静默互覆盖)。空闲则认领后钉回;占用则不钉 → 下次保存经 activeSlot 自然 fork
240
- // 到新槽(与 switchToSlot 的"占用则 fork"语义对齐)。
241
- const occ = slotOccupancy(getCwd(), slot)
242
- // 2026-09-01 会诊 kimi/glm 🔴:同进程双会话同槽——slotOccupancy 排除本进程属主后,
243
- // 同进程防护完全由 sameProcessPinned 承担:本进程另一 session 已钉该槽即视为占用
244
- // (进程级属主无法区分 agent,双方 sessionStart 相同 → F2 永不触发 → 静默互覆盖)。
245
- const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
246
- const pinned = !occ.occupied && !sameProcessPinned
247
- // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):钉槽(非占用)才绑定记录存储——
248
- // 占用/fork 分支模式 F(首保存 fork 新槽后补绑)
249
- applySession(session.agent, data, pinned ? { slot } : {})
250
- if (pinned) {
251
- const m = loadManifest(getCwd())
252
- m.slotSessions ??= {}
253
- m.slotSessions[slot] = getSessionId()
254
- saveManifest(getCwd(), m)
255
- session.agent._slot = slot
256
- } else {
257
- // 2026-09-01 advisor 🔴:占用时显式分配全新槽(fork)——原 `_slot = null` 的
258
- // fork 会经 saveSession → activeSlot → ensureActive 分支1 早退(slotSessions
259
- // [active] === mySessionId 同进程恒真)落回同进程 active 槽 → 两会话写同一槽
260
- // 静默互覆盖。newSession 跳过活认领号/现存文件号,必定落到新槽。
261
- session.agent._slot = newSession(getCwd())
262
- }
263
- sessions.set(id, session)
264
- // Replay the human line (role → chunk mapping, design §4.5) so the
265
- // client renders the restored conversation.
266
- replayHistory({ sessionId: id, notify: notifyRef.current, history: data.history, log })
267
- log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
268
- return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
269
- } catch (e) {
270
- return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${e.message}` } }
271
- }
272
- },
273
-
274
- "session/resume": async (params) => {
275
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
276
- const slot = Number(params.sessionId)
277
- if (!Number.isInteger(slot) || slot < 1) {
278
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
279
- }
280
- const data = loadSlotFile(getCwd(), slot)
281
- if (!data) {
282
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
283
- }
284
- try {
285
- const id = String(nextId++)
286
- const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
287
- // 2026-08-31 advisor round2 🟡:同 session/load——活主占用的槽不钉回(防同槽双写,
288
- // 下次保存 fork 新槽);空闲则认领后钉回。
289
- const occ = slotOccupancy(getCwd(), slot)
290
- // 2026-09-01 会诊 kimi/glm 🔴:同 session/load——同进程其他 session 已钉同槽视为占用 → fork
291
- const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
292
- const pinned = !occ.occupied && !sameProcessPinned
293
- // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):同 load——钉槽才绑定(占用/fork 模式 F)
294
- applySession(session.agent, data, pinned ? { slot } : {})
295
- if (pinned) {
296
- const m = loadManifest(getCwd())
297
- m.slotSessions ??= {}
298
- m.slotSessions[slot] = getSessionId()
299
- saveManifest(getCwd(), m)
300
- session.agent._slot = slot
301
- } else {
302
- // 2026-09-01 advisor 🔴:同 load——显式 newSession fork(_slot=null 的 fork 会
303
- // 落回同进程 active 槽 → 两会话写同一槽静默互覆盖)
304
- session.agent._slot = newSession(getCwd())
305
- }
306
- sessions.set(id, session)
307
- // resume: no history replay — the client keeps its own rendering.
308
- log(`session ${slot} resumed as session ${id} (no replay)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
309
- return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
310
- } catch (e) {
311
- return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${e.message}` } }
312
- }
313
- },
314
-
315
- "session/delete": (params) => {
316
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
317
- const slot = Number(params.sessionId)
318
- if (!Number.isInteger(slot) || slot < 1) {
319
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
320
- }
321
- // Only the persisted archive is removed; an active in-memory session
322
- // with the same id keeps running (design §4.5).
323
- if (!deleteSlot(getCwd(), slot)) {
324
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found` } }
325
- }
326
- // 2026-08-31 会诊 deepseek 🟡:被删槽的在存会话 _slot 仍钉着 → 下次保存会重建
327
- // 文件并重注册(删后复活)。清空其 _slot,下次保存重新认领新槽。
328
- // 2026-09-01 advisor round2 🔴:不能清 _slot 等下次保存——saveSession 走
329
- // activeSlot → ensureActive 分支1 早退(slotSessions[active] === mySessionId 同
330
- // 进程恒真)→ 落回同进程 active 槽(另一在存会话的槽)→ 两会话写同一槽静默
331
- // 互覆盖(sessionStart 均 null → F2 永不轮转)。与 load/resume 同型修复:
332
- // 立即 newSession 钉全新槽(跳过活认领号/现存文件号)。
333
- for (const s of sessions.values()) {
334
- if (s.agent?._slot === slot) s.agent._slot = newSession(getCwd())
335
- }
336
- log(`session ${slot} archive deleted`)
337
- return {}
338
- },
339
-
340
- "session/set_config_option": (params) => {
341
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
342
- const found = findSession(params)
343
- if (found.error) return found
344
- const { configId, value } = params
345
- if (!configId || value === undefined) {
346
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "set_config_option requires configId and value" } }
347
- }
348
- const applied = applyConfigOption(found.session.agent, configId, value)
349
- if (!applied) {
350
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown configId: ${configId}` } }
351
- }
352
- // Last-write-wins on the internal state; notify the client of the change.
353
- notifyRef.current("session/update", {
354
- sessionId: String(params.sessionId),
355
- update: { sessionUpdate: "config_option_update", configId, value },
356
- })
357
- return {}
358
- },
359
-
360
- "session/set_mode": (params) => {
361
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
362
- const found = findSession(params)
363
- if (found.error) return found
364
- if (params.mode !== "plan" && params.mode !== "normal") {
365
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "mode must be plan or normal" } }
366
- }
367
- found.session.agent.planMode = params.mode === "plan"
368
- notifyRef.current("session/update", {
369
- sessionId: String(params.sessionId),
370
- update: { sessionUpdate: "current_mode_update", mode: params.mode },
371
- })
372
- return {}
373
- },
374
-
375
- // ─── M5-pull-forward: checkpoints (desktop proposal ②) + memory (③) ───
376
- // Checkpoints are cwd-scoped (same store the TUI git tool uses); non-git cwds
377
- // now snapshot by full-directory copy instead of returning null.
378
-
379
- "checkpoint/create": async () => {
380
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
381
- const cp = await createCheckpoint(getCwd())
382
- if (!cp) return { error: { ...ACP_ERRORS.INTERNAL, message: "checkpoint creation failed" } }
383
- return { checkpoint: { id: cp.id, time: cp.time, files: cp.files, git: isGitRepo(getCwd()) } }
384
- },
385
-
386
- "checkpoint/list": async () => {
387
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
388
- const cps = await listCheckpoints(getCwd())
389
- return { checkpoints: cps.map((c) => ({ id: c.id, time: c.time, files: (c.tracked?.length ?? 0) + (c.untracked?.length ?? 0), trackedCount: c.tracked?.length ?? 0, untrackedCount: c.untracked?.length ?? 0 })) }
390
- },
391
-
392
- "checkpoint/restore": async (params) => {
393
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
394
- if (!params.checkpointId || !params.path) {
395
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "checkpoint/restore requires checkpointId and path (single-file restore; full rewind is disabled)" } }
396
- }
397
- try {
398
- await rewind(getCwd(), String(params.checkpointId), { path: String(params.path) })
399
- return { restored: String(params.path) }
400
- } catch (e) {
401
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: e?.message ?? String(e) } }
402
- }
403
- },
404
-
405
- "memory/list": async (params) => {
406
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
407
- const mem = ensureAcpMemory()
408
- if (!mem) return { error: { ...ACP_ERRORS.INTERNAL, message: "memory unavailable" } }
409
- const entries = await memList(mem, { type: params.type })
410
- return { entries: entries.map((e) => ({ id: e.id, type: e.type, title: e.title, tags: e.tags ?? "", updatedAt: e.updatedAt ?? null })) }
411
- },
412
-
413
- "memory/remove": async (params) => {
414
- if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
415
- const id = Number(params.id)
416
- if (!Number.isInteger(id) || id < 1) {
417
- return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `memory/remove requires a numeric id (got ${params.id})` } }
418
- }
419
- const mem = ensureAcpMemory()
420
- if (!mem) return { error: { ...ACP_ERRORS.INTERNAL, message: "memory unavailable" } }
421
- const ok = await memRemove(mem, id)
422
- return ok ? { removed: id } : { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `no memory entry #${id}` } }
423
- },
118
+ ...createCapsHandlers({ version, clientCaps, requireConfigured: ctx.requireConfigured, log }),
119
+ ...createSessionHandlers(ctx),
120
+ ...createSlotsHandlers(ctx),
121
+ ...createExtHandlers(ctx),
424
122
  },
425
123
  sessions,
426
124
  notifyRef,
@@ -428,18 +126,8 @@ export function buildAcpHandlers({
428
126
  }
429
127
  }
430
128
 
431
- /** Shared memory handle for ACP handlers (same ~/.thincoder store the TUI uses).
432
- * dbPath mirrors the TUI default (see cli/make-agent.mjs). */
433
- let _acpMemory = null
434
- function ensureAcpMemory() {
435
- if (_acpMemory) return _acpMemory
436
- try {
437
- _acpMemory = createMemory({ dbPath: join(configDir, "memory.db") })
438
- return _acpMemory
439
- } catch {
440
- return null // memory subsystem unavailable — handlers report it
441
- }
442
- }
129
+ /** `thincoder acp --login` — terminal auth flow (§11.2 改法 4; body = acp/login.mjs). */
130
+ export { runAcpLogin } from "./acp/login.mjs"
443
131
 
444
132
  /** `thincoder acp` — start the server and block until the client closes the pipe. */
445
133
  export async function runAcpServer() {
@@ -1,13 +1,13 @@
1
1
  import { isAbsolute, join } from "node:path"
2
- import { loadConfig, configPath } from "../config.mjs"
3
- import { createMemory } from "../memory.mjs"
2
+ import { loadConfig, configPath } from "@thincoder/core/config.mjs"
3
+ import { createMemory } from "@thincoder/core/memory.mjs"
4
4
  import { teamConfig, gitAuthor } from "./make-agent.mjs"
5
5
  import { setupWizard } from "./setup-wizard.mjs"
6
6
  import { askPermission } from "./permission.mjs"
7
7
 
8
8
  /** Unified message when no API key is configured */
9
9
  function noKeyMessage() {
10
- return `还没有配置 API key。运行 thincoder 进入 TUI,用 /provider add 和 /provider key 配置;或直接编辑 ${configPath}`
10
+ return `还没有配置 API key。运行 thincoder 进入 TUI,用 /model 添加渠道并设置 key;或直接编辑 ${configPath}`
11
11
  }
12
12
 
13
13
  /** thincoder distill <transcript-file> [--yes] [--layer=...]