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
@@ -1,448 +0,0 @@
1
- /**
2
- * checkpoint.mjs — workspace snapshot and rollback (v2: full-file copies)
3
- * Snapshot = full copies of changed tracked files + untracked file copies (respects .gitignore).
4
- * v1 stored only a git diff patch — rewind depended on HEAD being unchanged (a commit after the
5
- * snapshot made `git apply` fail and the failure recovery chain collapse). v2 copies files, so
6
- * rewind works regardless of later commits.
7
- * Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
8
- * rewind supports a path parameter for per-file restore.
9
- */
10
- import { execFileSync } from "node:child_process"
11
- import { createHash } from "node:crypto"
12
- import { existsSync, readFileSync, statSync } from "node:fs"
13
- import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
14
- import { dirname, join } from "node:path"
15
- import { configDir } from "../config.mjs"
16
-
17
- const CWD_HASH_LEN = 12
18
-
19
- const MAX_CHECKPOINTS = 100
20
-
21
- /** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
22
- const MAX_FILE_BYTES = 5 * 1024 * 1024
23
-
24
- /** meta.version 2 = full-copy snapshots; 1 = legacy patch-only snapshots */
25
- const META_VERSION = 2
26
-
27
- function git(cwd, args, { allowFail = false } = {}) {
28
- try {
29
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
30
- } catch (error) {
31
- if (allowFail) return null
32
- throw new Error(`git ${args.join(" ")} failed: ${error.stderr?.toString().trim() || error.message}`, { cause: error })
33
- }
34
- }
35
-
36
- /** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
37
- * extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
38
- * process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
39
- * contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
40
- function normalizeCwd(cwd) {
41
- return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
42
- }
43
-
44
- function checkpointRoot(cwd) {
45
- const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
46
- return join(configDir, "checkpoints", hash)
47
- }
48
-
49
- /** Extract the list of tracked files changed from a unified diff */
50
- function trackedFilesFromPatch(patch) {
51
- if (!patch.trim()) return []
52
- const files = new Set()
53
- for (const m of patch.matchAll(/^--- a\/(.+)$/gm)) files.add(m[1])
54
- return [...files].sort()
55
- }
56
-
57
- /** Extract a single file's patch hunks from a unified diff */
58
- function extractFileHunks(patch, filePath) {
59
- // Split by file: each file block starts with "diff --git"
60
- const sections = patch.split(/(?=^diff --git )/m)
61
- for (const sec of sections) {
62
- if (!sec.trim()) continue
63
- const m = sec.match(/^diff --git a\/(.+) b\/(.+)/m)
64
- if (!m) continue
65
- if (m[1] === filePath || m[2] === filePath) return sec.trim()
66
- }
67
- return ""
68
- }
69
-
70
- /** Whether the current directory is a git repo */
71
- export function isGitRepo(cwd) {
72
- return git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }) === "true"
73
- }
74
-
75
- /**
76
- * Copy a file into a snapshot directory, skipping oversized files.
77
- * Returns { size, sha } (sha = SHA256 hex, 12 chars) or null when skipped/failed.
78
- */
79
- async function copyInto(dir, rel, src, skipped) {
80
- let size
81
- try { size = statSync(src).size } catch { return null }
82
- if (size > MAX_FILE_BYTES) {
83
- skipped.push(rel)
84
- return null
85
- }
86
- const buf = await readFile(src).catch((e) => { console.error(`[checkpoint] skipping ${rel}: ${e.message}`); return null })
87
- if (!buf) return null
88
- const dst = join(dir, rel)
89
- await mkdir(dirname(dst), { recursive: true })
90
- await writeFile(dst, buf)
91
- return { size: buf.length, sha: createHash("sha256").update(buf).digest("hex").slice(0, 12) }
92
- }
93
-
94
- /**
95
- * Create a snapshot. Returns { id, time, files, tracked, untracked, skipped } or null (non-git repo).
96
- * Per-file metadata ({ size, sha } per copied file) is stored in meta.json — this powers
97
- * listFileVersions (per-file history across snapshots) without rescanning copies.
98
- */
99
- /** Full-directory snapshot for NON-git cwds (desktop proposal ②: createCheckpoint used to
100
- * return null here, silently disabling checkpoints for non-git projects). Every file is
101
- * copied under files/ (v2 layout, same rewind code path); meta carries nongit:true so
102
- * rewind knows to skip git plumbing. Respects .gitignore-like skips via SKIP set only. */
103
- async function createNonGitCheckpoint(cwd) {
104
- const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
105
- const dir = join(checkpointRoot(cwd), id)
106
- await mkdir(join(dir, "files"), { recursive: true })
107
- await mkdir(join(dir, "untracked"), { recursive: true })
108
-
109
- const skipped = []
110
- const fileMeta = {}
111
- const all = await collectFiles(cwd)
112
- for (const rel of all) {
113
- const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
114
- if (m) fileMeta[rel] = m
115
- }
116
- await writeFile(join(dir, "patch.diff"), "", "utf8") // no patch in nongit mode (layout compat)
117
- await writeFile(join(dir, "meta.json"), JSON.stringify({
118
- version: META_VERSION, id, time: Date.now(), nongit: true,
119
- untracked: [], tracked: all, trackedAll: all, skipped, head: "", fileMeta, untrackedMeta: {},
120
- }, null, 2), "utf8")
121
- await pruneCheckpoints(cwd)
122
- return { id, time: Date.now(), files: all.length, tracked: all, untracked: [], skipped }
123
- }
124
-
125
- /** Walk a non-git cwd collecting every file (relative paths), skipping heavy/irrelevant dirs. */
126
- async function collectFiles(cwd) {
127
- const SKIP = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", ".next", "target", "__pycache__", ".venv"])
128
- const out = []
129
- async function walk(rel) {
130
- const abs = rel ? join(cwd, rel) : cwd
131
- let entries
132
- try { entries = await readdir(abs, { withFileTypes: true }) } catch { return }
133
- for (const e of entries) {
134
- if (e.name.startsWith(".thincoder")) continue
135
- const child = rel ? `${rel}/${e.name}` : e.name
136
- if (e.isDirectory()) { if (!SKIP.has(e.name)) await walk(child) }
137
- else out.push(child)
138
- }
139
- }
140
- await walk("")
141
- return out
142
- }
143
-
144
- export async function createCheckpoint(cwd) {
145
- if (!isGitRepo(cwd)) return createNonGitCheckpoint(cwd)
146
-
147
- // Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
148
- const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
149
- const dir = join(checkpointRoot(cwd), id)
150
- await mkdir(join(dir, "files"), { recursive: true })
151
- await mkdir(join(dir, "untracked"), { recursive: true })
152
-
153
- // v2: full copies of changed tracked files (git apply of a stale patch is the v1 failure mode).
154
- // The patch is still written for legacy tooling/debugging, but rewind uses the copies.
155
- const head = git(cwd, ["rev-parse", "HEAD"], { allowFail: true }) ?? ""
156
- const changedRaw = git(cwd, ["diff", "HEAD", "--name-only", "-z"], { allowFail: true }) ?? ""
157
- const changed = changedRaw ? changedRaw.split("\0").filter(Boolean) : []
158
- const trackedAllRaw = git(cwd, ["ls-files", "-z"], { allowFail: true }) ?? ""
159
- const trackedAll = trackedAllRaw ? trackedAllRaw.split("\0").filter(Boolean) : []
160
- const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
161
- await writeFile(join(dir, "patch.diff"), patch, "utf8")
162
-
163
- const skipped = []
164
- const fileMeta = {}
165
- for (const rel of changed) {
166
- const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
167
- if (m) fileMeta[rel] = m
168
- }
169
-
170
- // Untracked files (respects .gitignore) → copy as-is
171
- const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
172
- const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
173
- const untrackedMeta = {}
174
- for (const rel of untracked) {
175
- const m = await copyInto(join(dir, "untracked"), rel, join(cwd, rel), skipped)
176
- if (m) untrackedMeta[rel] = m
177
- }
178
-
179
- await writeFile(join(dir, "meta.json"), JSON.stringify({
180
- version: META_VERSION, id, time: Date.now(), untracked, tracked: changed, skipped,
181
- head, trackedAll, fileMeta, untrackedMeta,
182
- }, null, 2), "utf8")
183
-
184
- await pruneCheckpoints(cwd)
185
- return { id, time: Date.now(), files: changed.length + untracked.length, tracked: changed, untracked, skipped }
186
- }
187
-
188
- /** List checkpoints (newest→oldest), with file change summary */
189
- export async function listCheckpoints(cwd) {
190
- const root = checkpointRoot(cwd)
191
- if (!existsSync(root)) return []
192
- const ids = (await readdir(root)).sort().reverse()
193
- const out = []
194
- for (const id of ids) {
195
- try {
196
- const meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8"))
197
- // Compat with old checkpoints (no tracked field): extract from patch file
198
- const tracked = meta.tracked ?? (() => {
199
- try {
200
- return trackedFilesFromPatch(readFileSync(join(root, id, "patch.diff"), "utf8"))
201
- } catch { return [] }
202
- })()
203
- out.push({
204
- id, time: meta.time,
205
- untracked: meta.untracked ?? [],
206
- skipped: meta.skipped ?? [],
207
- tracked,
208
- })
209
- } catch {
210
- // Corrupted checkpoint — skip
211
- }
212
- }
213
- return out
214
- }
215
-
216
- // ---- restore core ----
217
-
218
- /** v1 legacy fallback: reset file to HEAD then apply its patch hunks. */
219
- async function partialRestoreTrackedViaPatch(cwd, root, patchContent, filePath) {
220
- git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
221
- const hunks = extractFileHunks(patchContent, filePath)
222
- if (!hunks) return false
223
- const tmpFile = join(root, ".tmp_partial.patch")
224
- await writeFile(tmpFile, hunks, "utf8")
225
- try {
226
- git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
227
- return true
228
- } finally {
229
- await rm(tmpFile, { force: true })
230
- }
231
- }
232
-
233
- /** Restore a single file from a snapshot copy. Returns true when the copy existed. */
234
- async function restoreFromCopy(snapshotDir, rel, cwd) {
235
- const src = join(snapshotDir, rel)
236
- if (!existsSync(src)) return false
237
- await mkdir(dirname(join(cwd, rel)), { recursive: true })
238
- await cp(src, join(cwd, rel), { force: true })
239
- return true
240
- }
241
-
242
- // ---- restore (single-file only) ----
243
-
244
- /**
245
- * Restore ONE file from a specific snapshot (saves current state as a new snapshot first,
246
- * making the restore reversible).
247
- *
248
- * FULL restore is deliberately DISABLED: rolling the whole working tree back to a snapshot
249
- * is exactly as dangerous as `git checkout -- .` — it silently discards every change made
250
- * after the snapshot. Restore files individually (rewind with path / restoreFile).
251
- *
252
- * Returns { path, type, restored }.
253
- */
254
- export async function rewind(cwd, id, { path } = {}) {
255
- if (!path) {
256
- throw new Error(
257
- "Full rewind is disabled — it is as dangerous as `git checkout -- .` (silently discards all work after the snapshot). " +
258
- "Restore files individually: rewind(cwd, id, { path }) or restoreFile(cwd, path, id)."
259
- )
260
- }
261
-
262
- const root = checkpointRoot(cwd)
263
- const dir = join(root, id)
264
- if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
265
- const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
266
- const isV2 = meta.version === META_VERSION
267
- const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
268
-
269
- // Restore is reversible: snapshot current state first
270
- await createCheckpoint(cwd)
271
-
272
- const tracked = meta.tracked ?? []
273
- const trackedAll = meta.trackedAll ?? []
274
- const isTracked = tracked.includes(path) || trackedAll.includes(path) || extractFileHunks(patchContent, path) !== ""
275
- const inUntracked = (meta.untracked ?? []).includes(path)
276
-
277
- // Oversized files were never copied — nothing to restore, say so explicitly.
278
- if ((meta.skipped ?? []).includes(path)) {
279
- throw new Error(`file "${path}" was NOT snapshotted in checkpoint ${id} (oversized, >5MB) — no copy exists to restore`)
280
- }
281
-
282
- if (!isTracked && !inUntracked) {
283
- throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${tracked.join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
284
- }
285
-
286
- let ok = false
287
- if (inUntracked) {
288
- ok = await restoreFromCopy(join(dir, "untracked"), path, cwd) || ok
289
- }
290
- if (isTracked) {
291
- if (isV2 && existsSync(join(dir, "files", path))) {
292
- ok = await restoreFromCopy(join(dir, "files"), path, cwd) || ok
293
- } else if (isV2 && meta.head) {
294
- // Untouched at snapshot time (content = snapshot HEAD) → checkout that commit's version.
295
- // Works even if HEAD moved since — the commit object is immutable.
296
- git(cwd, ["checkout", meta.head, "--", path], { allowFail: true })
297
- ok = true
298
- } else {
299
- // v1 snapshot → patch fallback
300
- ok = await partialRestoreTrackedViaPatch(cwd, root, patchContent, path) || ok
301
- }
302
- }
303
-
304
- return { path, type: isTracked ? "tracked" : "untracked", restored: ok }
305
- }
306
-
307
- // ---- per-file history ----
308
-
309
- /**
310
- * List every historical version of a file across all snapshots (newest first).
311
- * Distinguishing copies: each entry carries its snapshot id, time, byte size and
312
- * content sha — the same file in different snapshots is a different version.
313
- * @param {string} cwd
314
- * @param {string} filePath — relative path as stored in snapshots
315
- * @returns {Promise<Array<{snapshotId: string, time: number, size: number, sha: string, source: "tracked"|"untracked"}>>}
316
- */
317
- export async function listFileVersions(cwd, filePath) {
318
- const root = checkpointRoot(cwd)
319
- if (!existsSync(root)) return []
320
- const ids = (await readdir(root)).sort().reverse() // newest → oldest
321
- const out = []
322
- for (const id of ids) {
323
- let meta
324
- try { meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8")) } catch { continue }
325
- const trackedMeta = meta.fileMeta ?? {}
326
- const untrackedMeta = meta.untrackedMeta ?? {}
327
- let entry = null
328
- if (trackedMeta[filePath]) {
329
- entry = { snapshotId: id, time: meta.time, ...trackedMeta[filePath], source: "tracked" }
330
- } else if (untrackedMeta[filePath]) {
331
- entry = { snapshotId: id, time: meta.time, ...untrackedMeta[filePath], source: "untracked" }
332
- } else if ((meta.tracked ?? []).includes(filePath) || (meta.untracked ?? []).includes(filePath)) {
333
- // Legacy snapshot (no per-file meta): fall back to stat-ing the copy
334
- const src = join((meta.tracked ?? []).includes(filePath) ? join(root, id, "files") : join(root, id, "untracked"), filePath)
335
- let size, sha
336
- try {
337
- const buf = await readFile(src)
338
- size = buf.length
339
- sha = createHash("sha256").update(buf).digest("hex").slice(0, 12)
340
- } catch { continue }
341
- entry = { snapshotId: id, time: meta.time, size, sha, source: (meta.tracked ?? []).includes(filePath) ? "tracked" : "untracked" }
342
- }
343
- if (entry) out.push(entry)
344
- }
345
- return out
346
- }
347
-
348
- /**
349
- * Restore a single file's content from a specific snapshot (per-file restore —
350
- * other files are left untouched). Thin wrapper over rewind path mode with clearer intent.
351
- * @returns {Promise<{path: string, type: "tracked"|"untracked", restored: boolean}>}
352
- */
353
- export async function restoreFile(cwd, filePath, snapshotId) {
354
- return rewind(cwd, snapshotId, { path: filePath })
355
- }
356
-
357
-
358
- // ---- view ----
359
-
360
- /**
361
- * View a file's content from a checkpoint (does not modify the working tree).
362
- * v2: read the snapshot copy directly. Legacy snapshots fall back to the temporary-restore path.
363
- * Returns the file content string.
364
- */
365
- export async function catFile(cwd, id, filePath) {
366
- const root = checkpointRoot(cwd)
367
- const dir = join(root, id)
368
- if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
369
- const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
370
- const isV2 = meta.version === META_VERSION
371
- const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
372
-
373
- const tracked = meta.tracked ?? []
374
- const isTracked = tracked.includes(filePath) || extractFileHunks(patchContent, filePath) !== ""
375
- const inUntracked = (meta.untracked ?? []).includes(filePath)
376
-
377
- if (!isTracked && !inUntracked) {
378
- throw new Error(`file "${filePath}" not in checkpoint ${id}`)
379
- }
380
-
381
- // v2: read the snapshot copy directly
382
- const copy = join(inUntracked ? join(dir, "untracked") : join(dir, "files"), filePath)
383
- if (isV2 && existsSync(copy)) {
384
- return await readFile(copy, "utf8")
385
- }
386
-
387
- // Untracked legacy: read the copy if present
388
- if (inUntracked && !isTracked) {
389
- const src = join(dir, "untracked", filePath)
390
- if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
391
- return await readFile(src, "utf8")
392
- }
393
-
394
- // Tracked legacy: temporarily restore → read → restore working tree
395
- const abs = join(cwd, filePath)
396
- const existed = existsSync(abs)
397
- let saved = null
398
- if (existed) saved = await readFile(abs, "utf8")
399
-
400
- try {
401
- git(cwd, ["checkout", "HEAD", "--", filePath])
402
- const hunks = extractFileHunks(patchContent, filePath)
403
- if (hunks) {
404
- const tmpPatch = join(root, ".tmp_cat.patch")
405
- await writeFile(tmpPatch, hunks, "utf8")
406
- try {
407
- git(cwd, ["apply", "--whitespace=nowarn", tmpPatch])
408
- } finally {
409
- await rm(tmpPatch, { force: true })
410
- }
411
- }
412
- return await readFile(abs, "utf8")
413
- } finally {
414
- // Restore original working tree state
415
- if (existed) {
416
- await writeFile(abs, saved, "utf8")
417
- } else {
418
- await rm(abs, { force: true })
419
- }
420
- }
421
- }
422
-
423
- /** F6: delete ALL checkpoints for a cwd — commit = new safety baseline. Best-effort:
424
- * a missing dir is a no-op; deletion failures propagate so the git tool's commit case
425
- * can report "(checkpoint cleanup skipped: …)" without blocking the commit result. */
426
- export async function deleteCheckpointsForCwd(cwd) {
427
- await rm(checkpointRoot(cwd), { recursive: true, force: true })
428
- }
429
-
430
- /** NF6: keep only the most recent `count` snapshots — oldest removed first (id sort =
431
- * timestamp prefix, so ascending order IS oldest-first). Returns how many were deleted
432
- * (0 when the cwd has no checkpoint dir). */
433
- export async function deleteCheckpointsOlderThan(cwd, count) {
434
- const root = checkpointRoot(cwd)
435
- let ids
436
- try { ids = (await readdir(root)).sort() } catch { return 0 }
437
- let removed = 0
438
- while (ids.length > count) {
439
- await rm(join(root, ids.shift()), { recursive: true, force: true })
440
- removed++
441
- }
442
- return removed
443
- }
444
-
445
- /** Keep only the most recent MAX_CHECKPOINTS (NF6 cap — runs at the end of every create) */
446
- async function pruneCheckpoints(cwd) {
447
- await deleteCheckpointsOlderThan(cwd, MAX_CHECKPOINTS)
448
- }
@@ -1,100 +0,0 @@
1
- /**
2
- * gitmem.mjs — Team-layer memory git sync
3
- * All through child_process calling system git, zero dependencies.
4
- * Conflict strategy (decided): different entries are naturally conflict-free;
5
- * on real conflicts abort rebase to keep the repo clean,
6
- * report an error with manual resolution guidance — no auto-merge.
7
- */
8
-
9
- import { execFile } from "node:child_process"
10
- import { existsSync } from "node:fs"
11
- import { mkdir } from "node:fs/promises"
12
- import { dirname, join } from "node:path"
13
- import { promisify } from "node:util"
14
-
15
- const execFileAsync = promisify(execFile)
16
-
17
- /** Run git in dir, throw with stderr on failure */
18
- async function git(dir, args) {
19
- try {
20
- const { stdout } = await execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
21
- return stdout.trim()
22
- } catch (error) {
23
- const detail = error.stderr?.trim() || error.message
24
- const err = new Error(`git ${args.join(" ")} failed: ${detail}`)
25
- err.gitError = true
26
- err.stderr = detail
27
- throw err
28
- }
29
- }
30
-
31
- /** Clone team repo if it doesn't exist. Returns whether a clone happened */
32
- export async function ensureClone({ repo, dir }) {
33
- if (existsSync(join(dir, ".git"))) return false
34
- await mkdir(dirname(dir), { recursive: true })
35
- await git(dirname(dir), ["clone", repo, dir])
36
- return true
37
- }
38
-
39
- /**
40
- * Sync: pull --rebase. Skip if remote is still empty (first-time use).
41
- * On conflict, abort rebase (keep repo clean) and throw a guided error.
42
- * Returns true = pull succeeded (caller should then syncDir to rebuild index)
43
- */
44
- export async function pullTeam(dir) {
45
- // Empty remote repo: no branch to pull (ls-remote has no output)
46
- const refs = await git(dir, ["ls-remote", "--heads", "origin"])
47
- if (!refs) return false
48
-
49
- try {
50
- await git(dir, ["pull", "--rebase"])
51
- return true
52
- } catch (error) {
53
- if (await hasConflict(dir)) {
54
- let abortFailed = false
55
- try { await git(dir, ["rebase", "--abort"]) } catch {
56
- abortFailed = true
57
- }
58
- const conflictError = new Error(
59
- `Team memory sync conflict: local and remote modified the same entry.\n` +
60
- `Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
61
- (abortFailed
62
- ? `(WARNING: git rebase --abort also failed — the repo may be in a conflicted state. ` +
63
- `Run \`cd ${dir} && git rebase --abort\` manually to clean up.)`
64
- : `(The local repo has been restored to its pre-sync state — nothing was lost.)`),
65
- { cause: error },
66
- )
67
- throw conflictError
68
- }
69
- throw error
70
- }
71
- }
72
-
73
- /**
74
- * Commit and push an entry file. On push rejection (remote has new commits),
75
- * pull --rebase then retry once; rebase conflicts also abort and error out.
76
- */
77
- export async function commitAndPush(dir, filename, message) {
78
- await git(dir, ["add", filename])
79
- // Nothing to commit (content unchanged) — this is normal idempotent behavior, not an error
80
- const dirty = await git(dir, ["status", "--porcelain", "--", filename])
81
- if (dirty) await git(dir, ["commit", "-m", message])
82
- try {
83
- await git(dir, ["push"])
84
- } catch {
85
- await pullTeam(dir) // Conflicts will throw a guided error here
86
- await git(dir, ["push"])
87
- }
88
- }
89
-
90
- /** Whether currently in a rebase conflict state (unmerged paths exist) */
91
- async function hasConflict(dir) {
92
- try {
93
- const out = await git(dir, ["status", "--porcelain"])
94
- // There are 7 unmerged states: DD AU UD UA DU AA UU — only checking UU/AA/DD misses 4 states with U,
95
- // which means the rebase is left mid-conflict (violating the "keep repo clean" promise)
96
- return out.split("\n").some((l) => l[0] === "U" || l[1] === "U" || l.startsWith("AA") || l.startsWith("DD"))
97
- } catch {
98
- return false
99
- }
100
- }
package/src/hooks.mjs DELETED
@@ -1,97 +0,0 @@
1
- /**
2
- * hooks.mjs — Lifecycle hook engine
3
- *
4
- * Hooks are user-defined shell commands executed at key agent lifecycle points.
5
- * Configured in ~/.thincoder/config.json under "hooks".
6
- *
7
- * Event types:
8
- * PreToolUse — before each tool call (can block execution)
9
- * PostToolUse — after successful tool execution
10
- * PostToolUseFailure — after failed tool execution
11
- * Stop — main-session run end (no tool name; block is meaningless)
12
- *
13
- * Each hook: { matcher?, command, args?, timeout?, action? }
14
- * matcher: regex against tool name (default: match all)
15
- * command: executable path/name
16
- * args: optional CLI args; without args, stdin receives JSON payload
17
- * timeout: ms (default 10000)
18
- * action: "allow" | "block" | "notify" (default "notify")
19
- *
20
- * "block" hooks: exit code 0 = allow, non-zero = block.
21
- * "allow"/"notify": exit code ignored.
22
- */
23
-
24
- import { spawn } from "node:child_process"
25
-
26
- /** @param {string} event @param {object} ctx @returns {Promise<boolean>} false if blocked */
27
- export async function runHooks(event, ctx) {
28
- const hooks = ctx.agent?.config?.hooks?.[event]
29
- if (!hooks?.length) return true
30
-
31
- for (const hook of hooks) {
32
- // Matcher = tool-name regex: tool events always carry a toolName; no-tool-name
33
- // events (Stop) ignore it — a configured matcher must not silently never fire.
34
- if (hook.matcher && ctx.toolName != null) {
35
- try {
36
- if (!new RegExp(hook.matcher).test(ctx.toolName)) continue
37
- } catch { /* invalid regex → skip */ }
38
- }
39
-
40
- const allowed = await runOneHook(event, hook, ctx)
41
- if (hook.action === "block" && !allowed) return false
42
- }
43
- return true
44
- }
45
-
46
- async function runOneHook(event, hook, ctx) {
47
- const payload = JSON.stringify({
48
- event,
49
- toolName: ctx.toolName ?? null,
50
- toolArgs: ctx.toolArgs ?? null,
51
- result: ctx.result ?? null,
52
- error: ctx.error?.message ?? null,
53
- // Event-specific fields (Stop: turn / reason) — existing callers pass no extra ⇒ payload unchanged.
54
- ...(ctx.extra ?? {}),
55
- timestamp: new Date().toISOString(),
56
- })
57
-
58
- return new Promise((resolve) => {
59
- const timeout = hook.timeout ?? 10_000
60
- let settled = false
61
- const done = (code) => {
62
- if (settled) return
63
- settled = true
64
- resolve(code === 0)
65
- }
66
-
67
- let proc
68
- try {
69
- if (hook.args?.length) {
70
- proc = spawn(hook.command, hook.args, {
71
- stdio: ["pipe", "ignore", "ignore"],
72
- timeout,
73
- windowsHide: true,
74
- })
75
- } else {
76
- proc = spawn(hook.command, [], {
77
- stdio: ["pipe", "ignore", "ignore"],
78
- timeout,
79
- windowsHide: true,
80
- })
81
- }
82
- } catch {
83
- // command not found or spawn failure — don't block, don't crash
84
- return resolve(true)
85
- }
86
-
87
- proc.on("error", () => done(0)) // spawn failure → allow
88
- proc.on("close", (code) => done(code ?? 0))
89
- proc.on("exit", (code) => done(code ?? 0))
90
-
91
- // Send payload via stdin
92
- try { proc.stdin?.end(payload) } catch { /* */ }
93
-
94
- // Timeout guard
95
- setTimeout(() => done(0), timeout + 1000)
96
- })
97
- }