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.
- package/CHANGELOG.md +27 -0
- package/README.md +46 -49
- package/bin/thincoder.mjs +68 -34
- package/package.json +7 -6
- package/src/acp/bridge.mjs +35 -15
- package/src/acp/client-caps.mjs +86 -0
- package/src/acp/ext.mjs +86 -0
- package/src/acp/handlers-session.mjs +257 -0
- package/src/acp/handlers-slots.mjs +196 -0
- package/src/acp/login.mjs +48 -0
- package/src/acp/session.mjs +6 -4
- package/src/acp.mjs +67 -379
- package/src/cli/distill-command.mjs +3 -3
- package/src/cli/make-agent.mjs +60 -17
- package/src/cli/memory-command.mjs +3 -3
- package/src/cli/permission.mjs +4 -48
- package/src/cli/setup-wizard.mjs +1 -1
- package/src/completions.mjs +3 -1
- package/src/crash-reports.mjs +1 -1
- package/src/distill.mjs +4 -4
- package/src/heap-watch.mjs +1 -1
- package/src/prompt-injections.mjs +20 -0
- package/src/tui/agent-turn.mjs +40 -9
- package/src/tui/cmd-advisor.mjs +5 -5
- package/src/tui/cmd-config.mjs +8 -8
- package/src/tui/cmd-eng.mjs +35 -9
- package/src/tui/cmd-mcp.mjs +9 -8
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +6 -5
- package/src/tui/cmd-plan.mjs +9 -0
- package/src/tui/cmd-reindex.mjs +1 -1
- package/src/tui/cmd-restore.mjs +2 -2
- package/src/tui/cmd-session.mjs +24 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +22 -9
- package/src/tui/config-helpers.mjs +1 -1
- package/src/tui/display-budget.mjs +33 -11
- package/src/tui/index.mjs +18 -10
- package/src/tui/interaction.mjs +16 -7
- package/src/tui/key-modes.mjs +9 -4
- package/src/tui/ledger-surface.mjs +22 -59
- package/src/tui/model-catalog.mjs +4 -4
- package/src/tui/model-picker.mjs +8 -7
- package/src/tui/mouse.mjs +11 -6
- package/src/tui/pickers.mjs +15 -2
- package/src/tui/render-conversation.mjs +1 -1
- package/src/tui/render-frame.mjs +15 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render-segments.mjs +3 -1
- package/src/tui/slash-commands.mjs +1 -1
- package/src/tui/startup.mjs +14 -14
- package/src/tui/subagent-blocks.mjs +20 -3
- package/src/tui/subagent-freeze.mjs +73 -2
- package/src/tui/suspension-drive.mjs +57 -23
- package/src/tui/tool-events.mjs +11 -8
- package/src/tui/tui-lifecycle.mjs +9 -0
- package/src/tui/wizard.mjs +3 -3
- package/src/tui/wrapped-spawn.mjs +6 -2
- package/src/abort-provenance.mjs +0 -116
- package/src/advisor/citations.mjs +0 -139
- package/src/advisor/compaction.mjs +0 -174
- package/src/advisor/convergence.mjs +0 -80
- package/src/advisor/history.mjs +0 -77
- package/src/advisor/loop.mjs +0 -293
- package/src/advisor/messages.mjs +0 -299
- package/src/advisor/project-context.mjs +0 -194
- package/src/advisor/repos.mjs +0 -150
- package/src/advisor/run.mjs +0 -293
- package/src/advisor/truncate.mjs +0 -57
- package/src/advisor.mjs +0 -290
- package/src/agent/completion.mjs +0 -146
- package/src/agent/dispatch.mjs +0 -489
- package/src/agent/helpers.mjs +0 -384
- package/src/agent/post-turn.mjs +0 -70
- package/src/agent/record-results.mjs +0 -174
- package/src/agent/relay-prefix.mjs +0 -39
- package/src/agent/run-stages.mjs +0 -244
- package/src/agent/setup-reminders.mjs +0 -69
- package/src/agent/setup.mjs +0 -354
- package/src/agent/spawn-child.mjs +0 -243
- package/src/agent-tools/advisor-async.mjs +0 -346
- package/src/agent-tools/advisor-settle.mjs +0 -231
- package/src/agent-tools/advisor.mjs +0 -260
- package/src/agent-tools/async-settle.mjs +0 -204
- package/src/agent-tools/batch-segment.mjs +0 -195
- package/src/agent-tools/consult.mjs +0 -473
- package/src/agent-tools/design-token.mjs +0 -117
- package/src/agent-tools/digest-budget.mjs +0 -76
- package/src/agent-tools/eng.mjs +0 -67
- package/src/agent-tools/escalate-async.mjs +0 -295
- package/src/agent-tools/goal.mjs +0 -119
- package/src/agent-tools/plan.mjs +0 -81
- package/src/agent-tools/read-history.mjs +0 -309
- package/src/agent-tools/recent-changes.mjs +0 -24
- package/src/agent-tools/review-streak.mjs +0 -93
- package/src/agent-tools/settings.mjs +0 -265
- package/src/agent-tools/skill.mjs +0 -47
- package/src/agent-tools/subagent-actions.mjs +0 -482
- package/src/agent-tools/subagent-async.mjs +0 -434
- package/src/agent-tools/subagent-panel.mjs +0 -160
- package/src/agent-tools/subagent-run.mjs +0 -205
- package/src/agent-tools/subagent-scheduler.mjs +0 -392
- package/src/agent-tools/subagent-spawn.mjs +0 -459
- package/src/agent-tools/subagent.mjs +0 -404
- package/src/agent-tools/task.mjs +0 -87
- package/src/agent-tools/timer.mjs +0 -46
- package/src/agent-tools/verify.mjs +0 -271
- package/src/agent-tools.mjs +0 -17
- package/src/agent.mjs +0 -417
- package/src/auto-think.mjs +0 -115
- package/src/config-migrate.mjs +0 -70
- package/src/config.mjs +0 -496
- package/src/context.mjs +0 -392
- package/src/conventions.mjs +0 -223
- package/src/embedding.mjs +0 -120
- package/src/escape.mjs +0 -152
- package/src/expand-home.mjs +0 -16
- package/src/explore-distill.mjs +0 -155
- package/src/generate-title.mjs +0 -88
- package/src/git/checkpoint.mjs +0 -448
- package/src/git/gitmem.mjs +0 -100
- package/src/hooks.mjs +0 -97
- package/src/ledger.mjs +0 -227
- package/src/log.mjs +0 -195
- package/src/markdown.mjs +0 -106
- package/src/mcp/helpers.mjs +0 -51
- package/src/mcp/transport-http.mjs +0 -248
- package/src/mcp/transport-stdio.mjs +0 -140
- package/src/mcp/transport-ws.mjs +0 -122
- package/src/mcp.mjs +0 -295
- package/src/memory/code-index.mjs +0 -219
- package/src/memory/code-sync.mjs +0 -415
- package/src/memory/core.mjs +0 -299
- package/src/memory/delete.mjs +0 -236
- package/src/memory/docs.mjs +0 -419
- package/src/memory/file-walk.mjs +0 -109
- package/src/memory/scan.mjs +0 -95
- package/src/memory/schema.mjs +0 -452
- package/src/memory.mjs +0 -21
- package/src/model-ref.mjs +0 -66
- package/src/model-specs.mjs +0 -179
- package/src/peer-domains.mjs +0 -265
- package/src/peer-instances.mjs +0 -231
- package/src/prompt-overlays.mjs +0 -82
- package/src/prompts/advisor-design.md +0 -41
- package/src/prompts/advisor-round1.md +0 -41
- package/src/prompts/advisor-round2.md +0 -46
- package/src/prompts/advisor-round3.md +0 -42
- package/src/prompts/common.md +0 -115
- package/src/prompts/consult-base.md +0 -19
- package/src/prompts/discipline-engineering.md +0 -258
- package/src/prompts/discipline-normal.md +0 -185
- package/src/prompts/persona-coder.md +0 -21
- package/src/prompts/persona-eng-coder.md +0 -37
- package/src/prompts/persona-eng-designer.md +0 -60
- package/src/prompts/persona-engineering.md +0 -55
- package/src/prompts/persona-explore.md +0 -15
- package/src/prompts/persona-normal.md +0 -27
- package/src/prompts/persona-plan.md +0 -26
- package/src/provider/anthropic.mjs +0 -225
- package/src/provider/core.mjs +0 -476
- package/src/provider/errors.mjs +0 -101
- package/src/provider/google.mjs +0 -257
- package/src/provider/index.mjs +0 -7
- package/src/provider/list-models.mjs +0 -93
- package/src/provider/normalize.mjs +0 -81
- package/src/provider/rate.mjs +0 -108
- package/src/provider/responses.mjs +0 -495
- package/src/provider/retry.mjs +0 -88
- package/src/provider/sse.mjs +0 -264
- package/src/proxy.mjs +0 -261
- package/src/rules.mjs +0 -53
- package/src/session-gc.mjs +0 -221
- package/src/session-guard.mjs +0 -59
- package/src/session-migrate.mjs +0 -48
- package/src/session-rename.mjs +0 -38
- package/src/session-segments.mjs +0 -100
- package/src/session-slots.mjs +0 -492
- package/src/session-store.mjs +0 -441
- package/src/session.mjs +0 -492
- package/src/skills.mjs +0 -153
- package/src/text-budget.mjs +0 -46
- package/src/token-ttl.mjs +0 -274
- package/src/tools/apply_patch.md +0 -15
- package/src/tools/bash.md +0 -37
- package/src/tools/bash.mjs +0 -268
- package/src/tools/checklist-sync.mjs +0 -181
- package/src/tools/checklist.md +0 -13
- package/src/tools/checklist.mjs +0 -299
- package/src/tools/delete.md +0 -13
- package/src/tools/edit-batch.mjs +0 -191
- package/src/tools/edit-diff.mjs +0 -348
- package/src/tools/edit.md +0 -30
- package/src/tools/execute.md +0 -21
- package/src/tools/execute.mjs +0 -228
- package/src/tools/fetch.md +0 -12
- package/src/tools/file.mjs +0 -469
- package/src/tools/file_ops.md +0 -17
- package/src/tools/get_current_time.md +0 -8
- package/src/tools/git-checkpoint.mjs +0 -143
- package/src/tools/git-ext.mjs +0 -173
- package/src/tools/git.md +0 -54
- package/src/tools/git.mjs +0 -356
- package/src/tools/glob-dialect.mjs +0 -130
- package/src/tools/glob.md +0 -11
- package/src/tools/grep.md +0 -19
- package/src/tools/hashline_edit.md +0 -14
- package/src/tools/index.mjs +0 -36
- package/src/tools/insert_after.md +0 -15
- package/src/tools/lint.md +0 -10
- package/src/tools/linter.mjs +0 -128
- package/src/tools/ls.md +0 -12
- package/src/tools/lsp.md +0 -10
- package/src/tools/lsp.mjs +0 -316
- package/src/tools/ops.mjs +0 -299
- package/src/tools/patch.mjs +0 -282
- package/src/tools/process.md +0 -10
- package/src/tools/question.md +0 -16
- package/src/tools/question.mjs +0 -26
- package/src/tools/read.md +0 -20
- package/src/tools/read_image.md +0 -8
- package/src/tools/repomap.mjs +0 -314
- package/src/tools/search.mjs +0 -236
- package/src/tools/shared.mjs +0 -446
- package/src/tools/tree.md +0 -14
- package/src/tools/tree.mjs +0 -66
- package/src/tools/wait_for.md +0 -22
- package/src/tools/web.mjs +0 -224
- package/src/tools/websearch.md +0 -16
- package/src/tools/write.md +0 -11
- package/src/traces/trace-store.mjs +0 -355
package/src/git/checkpoint.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/git/gitmem.mjs
DELETED
|
@@ -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
|
-
}
|