thincoder 0.8.12 → 0.9.0
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/README.md +27 -0
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +20 -2
- package/src/agent/setup.mjs +0 -3
- package/src/agent-tools/goal.mjs +38 -1
- package/src/agent.mjs +6 -7
- package/src/auto-think.mjs +1 -1
- package/src/config.mjs +2 -0
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/hooks.mjs +93 -0
- package/src/mcp/transport-http.mjs +3 -2
- package/src/memory/docs.mjs +1 -2
- package/src/provider/core.mjs +8 -1
- package/src/session.mjs +8 -2
- package/src/tools/checklist.md +4 -1
- package/src/tools/checklist.mjs +134 -22
- package/src/tools/file.mjs +37 -9
- package/src/tools/patch.mjs +7 -3
- package/src/tools/repomap.mjs +7 -3
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +8 -0
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +2 -2
- package/src/tui/cmd-fold.mjs +21 -0
- package/src/tui/cmd-mcp.mjs +42 -1
- package/src/tui/cmd-undo.mjs +91 -0
- package/src/tui/index.mjs +167 -53
- package/src/tui/key-handler.mjs +2 -2
- package/src/tui/layout.mjs +3 -3
- package/src/tui/render-frame.mjs +266 -171
- package/src/tui/slash-commands.mjs +6 -0
package/README.md
CHANGED
|
@@ -205,6 +205,33 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
205
205
|
|
|
206
206
|
## Changelog
|
|
207
207
|
|
|
208
|
+
### 0.9.0 (2026-07)
|
|
209
|
+
- **Config JSON Schema** — `saveConfig` auto-injects `$schema` reference; `docs/schemas/config.schema.json` provides editor autocompletion/validation for all config fields including the new `hooks` section.
|
|
210
|
+
- **Lifecycle Hooks** — `PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `Notification` events. User-defined shell commands in config, with per-tool regex matching, timeout control, and `block`/`allow`/`notify` actions. Implemented in `src/hooks.mjs`, integrated into tool dispatch.
|
|
211
|
+
- **Built-in Skills (5)** — `pdf-create`, `xlsx-create`, `frontend-design`, `code-review`, `api-design` ship with the installation. Each is a standalone markdown instruction file using zero-dependency approaches (Chrome headless for PDF, PowerShell for Excel, etc.).
|
|
212
|
+
- **Conversation message folding** — Long tool result blocks (>8 consecutive dim lines) auto-collapse to first 2 lines + "… N more lines — Enter to expand". `/fold on|off` toggles globally.
|
|
213
|
+
- **Tree-shaped tasks** — `checklist` tool now supports hierarchical task IDs (`T1`, `T1.1`, `T1.2.1`) with auto-assigned numbering. `add` accepts `parent` parameter for subtree positioning. Indentation-based persistence in `checklist.md`.
|
|
214
|
+
- **AI-native MCP config** — `/mcp add` picker now includes "Describe with AI" option. Describe a server in natural language → model generates config JSON → preview + confirm → save and connect.
|
|
215
|
+
- **Goal judge model** — `goal complete` now runs an independent LLM check: goal criteria + recent agent activity → judge model verifies YES/NO. Prevents false completion claims during autonomous work.
|
|
216
|
+
- **`/undo` command** — Tracks up to 50 write/edit/delete operations in `agent._undoStack`. `/undo` opens a picker showing each operation's file and original content size. Select to revert.
|
|
217
|
+
- **Roadmap**: LSP tool, intelligent context management (checkpoint-based reconstruction), and CodeMode sandboxed JS are planned for 0.10.0.
|
|
218
|
+
|
|
219
|
+
### 0.8.13 (2026-07)
|
|
220
|
+
- **TUI: incremental rendering** — panel-level cache (`panelCache`) with sync-update bracketing (`DECSET 2026`). Only redraws changed panels, eliminating flicker. `saveCursor`/`restoreCursor` for efficient cursor positioning. Panel order reorganized: `header → conversation → subagent → output → todo → picker → permission → queue → input → status`.
|
|
221
|
+
- **Ctrl+I inject resume** — Ctrl+I (or Tab during processing) now properly interrupts, injects the message, and *resumes* the agent loop. Controller is recreated after abort. Added active signal check in SSE read loop for faster abort on Windows.
|
|
222
|
+
- **Processing hints** — Input box shows "Ctrl+U clear" hint during processing. Tab during processing treated as Ctrl+I. Slash commands re-render the frame.
|
|
223
|
+
- **Compression visibility** — `compressIfNeeded` now forwards `onToken`/`onReasoning` callbacks, making compression activity visible in the TUI.
|
|
224
|
+
- **Session: data-preserving fallback** — when atomic rename fails during session save, fall back to direct write instead of losing data.
|
|
225
|
+
- **Distill: balanced-bracket JSON extraction** — handles nested arrays in LLM output (e.g. `"tags": ["a", "b"]`), replacing the broken non-greedy regex approach.
|
|
226
|
+
- **File tools: EOL normalization** — `normalizeEOL` (`\r\n` → `\n`) applied on all reads (`read`, `edit`, `hashline_edit`, `insert_after`, `grep`, `repomap`), making hash computation and string matching platform-consistent.
|
|
227
|
+
- **hashline_edit: multiple-match detection** — when a hash sequence matches multiple positions, reports all with surrounding context instead of silently picking one.
|
|
228
|
+
- **delete: symlink-safe** — uses `lstat` instead of `stat` to correctly identify symlinks (not directories even if pointing to one).
|
|
229
|
+
- **repomap: large file guard** — skip files >10MB in dependency outline builds to prevent OOM.
|
|
230
|
+
- **Improved error messages** — `grep` and `insert_after` now catch invalid regex patterns at validation time with clear error messages.
|
|
231
|
+
- **Advisor session persistence** — advisor config saved/restored across sessions.
|
|
232
|
+
- **Timeout hardening** — auto-think uses `AbortSignal.timeout(5s)`, embedding requests add 60s timeout, MCP HTTP connect uses `INIT_TIMEOUT_MS`, fetch timeout extended to 10 minutes.
|
|
233
|
+
- **ClearScreen on exit** — terminal restored with `clearScreen` ANSI on TUI cleanup.
|
|
234
|
+
|
|
208
235
|
### 0.8.12 (2026-07)
|
|
209
236
|
- **Indexing: git-repo-only** — `codeSync` and `docSync` now only index inside git worktrees (via `git ls-files`), respecting `.gitignore`. Non-git directories get empty indexes. Prevents 2.9GB memory.db from accidentally indexing entire user profiles (AppData, browser extensions, Office add-ins, Program Files)
|
|
210
237
|
- **Indexing: file-size caps** — code files >1MB and doc files >512KB are skipped during bulk indexing (minified bundles, test fixtures, generated code)
|
package/package.json
CHANGED
package/src/agent/dispatch.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* agent/dispatch.mjs — two-phase tool call execution
|
|
3
3
|
*/
|
|
4
4
|
import { offloadToolResult } from "./helpers.mjs"
|
|
5
|
+
import { runHooks } from "../hooks.mjs"
|
|
6
|
+
import { snapshotForUndo } from "../tui/cmd-undo.mjs"
|
|
5
7
|
import { writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
6
8
|
import { join } from "node:path"
|
|
7
9
|
import { homedir } from "node:os"
|
|
@@ -80,6 +82,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
callbacks.onToolCall?.(toolCall.name, args)
|
|
85
|
+
|
|
86
|
+
// PreToolUse hooks: allow user scripts to gate tool execution
|
|
87
|
+
if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
|
|
88
|
+
prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
prepared.push({ toolCall, tool, args })
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -91,11 +100,17 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
91
100
|
? "Error: plan mode is active — only read-only tools are allowed. Exit plan mode first."
|
|
92
101
|
: item.reason === "denied by user"
|
|
93
102
|
? "Error: permission denied by user"
|
|
94
|
-
:
|
|
103
|
+
: item.reason === "blocked by PreToolUse hook"
|
|
104
|
+
? "Error: blocked by PreToolUse hook"
|
|
105
|
+
: "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
|
|
95
106
|
return { ...item, result: reason, ok: false }
|
|
96
107
|
}
|
|
97
108
|
try {
|
|
98
|
-
|
|
109
|
+
// Snapshot for undo before side-effect tools
|
|
110
|
+
if (item.tool?.outputPanel) callbacks.setupOutputPanel?.(item.toolCall.name)
|
|
111
|
+
if (!item.tool?.readonly && item.args) {
|
|
112
|
+
snapshotForUndo(agent, item.toolCall.name, item.args, agent.cwd)
|
|
113
|
+
}
|
|
99
114
|
const rawResult = await item.tool.execute(item.args, {
|
|
100
115
|
cwd: agent.cwd,
|
|
101
116
|
agent,
|
|
@@ -110,10 +125,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
110
125
|
const raw = String(rawResult)
|
|
111
126
|
const result = item.toolCall.name === "read_image" ? raw : await offloadToolResult(raw, item.toolCall.id)
|
|
112
127
|
callbacks.onToolResult?.(item.toolCall.name, result)
|
|
128
|
+
// PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
|
|
129
|
+
runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
|
|
113
130
|
return { ...item, result, ok: true }
|
|
114
131
|
} catch (error) {
|
|
115
132
|
// Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
|
|
116
133
|
logToolError(item.toolCall.name, item.args, error)
|
|
134
|
+
runHooks("PostToolUseFailure", { agent, toolName: item.toolCall.name, toolArgs: item.args, error }).catch(() => {})
|
|
117
135
|
return { ...item, result: `Error: ${error.message}`, ok: false }
|
|
118
136
|
}
|
|
119
137
|
}
|
package/src/agent/setup.mjs
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
|
|
3
3
|
*/
|
|
4
|
-
import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
|
|
5
4
|
import { search as memorySearch, docSearch } from "../memory.mjs"
|
|
6
5
|
import { toOpenAISchema } from "../tools/index.mjs"
|
|
7
6
|
import { loadSkills, formatSkillListing } from "../skills.mjs"
|
|
8
|
-
import { specForModel } from "../config.mjs"
|
|
9
|
-
import { join } from "node:path"
|
|
10
7
|
import {
|
|
11
8
|
escapeXml, repairHistory, listWorkDir, readonlyToolNames,
|
|
12
9
|
collectGitContext, loadProjectInstructions, OUTLINE_INJECT_PREFIX,
|
package/src/agent-tools/goal.mjs
CHANGED
|
@@ -52,8 +52,45 @@ export const goalTool = {
|
|
|
52
52
|
if (agent._mutatedThisRun && !agent._verifiedThisRun) {
|
|
53
53
|
return "Error: files were modified but verify has not run. Run the check your criteria names AND the verify tool before marking the goal complete — false completion is the worst outcome of autonomous work."
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
// Independent judge: verify the goal was actually achieved
|
|
57
|
+
// Only applies when the agent is at depth 0 (not a subagent) and has history to review
|
|
58
|
+
if (ctx.depth === 0 && agent.history.length > 2) {
|
|
59
|
+
try {
|
|
60
|
+
// Extract recent activity: last 4 assistant messages (summarizing what was done)
|
|
61
|
+
const recent = agent.history.filter(m => m.role === "assistant").slice(-4)
|
|
62
|
+
const activity = recent.map(m => (m.content ?? "").slice(0, 500)).join("\n---\n")
|
|
63
|
+
const { chat } = await import("../provider/index.mjs")
|
|
64
|
+
const judgeRes = await chat(agent.provider, {
|
|
65
|
+
messages: [{
|
|
66
|
+
role: "user",
|
|
67
|
+
content: `You are an independent goal judge. Evaluate whether this goal has been achieved based on the agent's activity.
|
|
68
|
+
|
|
69
|
+
Goal: ${agent.goal.objective}
|
|
70
|
+
Success criteria: ${agent.goal.criteria}
|
|
71
|
+
|
|
72
|
+
Recent agent activity:
|
|
73
|
+
${activity || "(no activity recorded)"}
|
|
74
|
+
|
|
75
|
+
Has this goal been achieved? Answer ONLY "YES" or "NO" followed by a one-sentence reason.`,
|
|
76
|
+
}],
|
|
77
|
+
tools: [],
|
|
78
|
+
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
})
|
|
80
|
+
const verdict = (judgeRes.content ?? "").trim()
|
|
81
|
+
if (verdict.toUpperCase().startsWith("NO")) {
|
|
82
|
+
return `Goal NOT complete (judge says NO): ${verdict.slice(2).trim()}\n\nContinue working or report blocked if this is a true impasse.`
|
|
83
|
+
}
|
|
84
|
+
if (!verdict.toUpperCase().startsWith("YES")) {
|
|
85
|
+
return `Goal completion unverified — judge response ambiguous: "${verdict.slice(0, 200)}". Re-check your criteria and try again with clear evidence.`
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Judge unavailable — allow completion but note it
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
agent.goal.status = "complete"
|
|
56
|
-
return `Goal
|
|
93
|
+
return `Goal verified complete ✓: ${agent.goal.objective}\nIn your next message, summarize the evidence (what check ran, what it showed) — the user should be able to audit this claim.`
|
|
57
94
|
}
|
|
58
95
|
if (args.action === "blocked") {
|
|
59
96
|
if (!args.reason) return "Error: 'reason' required for 'blocked' action."
|
package/src/agent.mjs
CHANGED
|
@@ -92,7 +92,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
92
92
|
const lastRole = agent.history.at(-1)?.role
|
|
93
93
|
if (lastRole === "user" || lastRole === "tool") {
|
|
94
94
|
try {
|
|
95
|
-
if (await compressIfNeeded(agent, threshold)) {
|
|
95
|
+
if (await compressIfNeeded(agent, threshold, callbacks)) {
|
|
96
96
|
agent._compressFailures = 0
|
|
97
97
|
recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
|
|
98
98
|
callbacks.onCompress?.()
|
|
@@ -118,7 +118,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
118
118
|
// Runs only on turn 0 of user input; failure is silent — falls back to current setting.
|
|
119
119
|
if (agent.config?.agent?.autoThink && turn === 0) {
|
|
120
120
|
const { classifyAndApply } = await import("./auto-think.mjs")
|
|
121
|
-
classifyAndApply(agent, turn).catch(() => {})
|
|
121
|
+
await classifyAndApply(agent, turn).catch(() => {})
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
try {
|
|
@@ -132,14 +132,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
132
132
|
})
|
|
133
133
|
} catch (e) {
|
|
134
134
|
// User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
|
|
135
|
-
//
|
|
136
|
-
// Inject the user's message into history and retry from the same context.
|
|
135
|
+
// Inject the message into history and let the outer loop recreate the controller.
|
|
137
136
|
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
138
137
|
agent.history.push({
|
|
139
138
|
role: "user",
|
|
140
139
|
content: `[User interrupt: ${signal.reason.message}]`,
|
|
141
140
|
})
|
|
142
|
-
continue
|
|
143
141
|
}
|
|
144
142
|
throw e
|
|
145
143
|
}
|
|
@@ -170,7 +168,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
170
168
|
}
|
|
171
169
|
|
|
172
170
|
// User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
|
|
173
|
-
// was partially generated. Commit partial output + inject user message, then
|
|
171
|
+
// was partially generated. Commit partial output + inject user message, then signal
|
|
172
|
+
// the outer loop to recreate the controller and resume.
|
|
174
173
|
if (response.interrupted) {
|
|
175
174
|
if (response.content) {
|
|
176
175
|
agent.history.push({ role: "assistant", content: response.content })
|
|
@@ -179,7 +178,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
179
178
|
role: "user",
|
|
180
179
|
content: `[User interrupt: ${response.interruptMessage}]`,
|
|
181
180
|
})
|
|
182
|
-
|
|
181
|
+
throw Object.assign(new Error("User interrupted"), { name: "AbortError" })
|
|
183
182
|
}
|
|
184
183
|
|
|
185
184
|
if (response.usage) {
|
package/src/auto-think.mjs
CHANGED
|
@@ -62,7 +62,7 @@ export async function classifyAndApply(agent, turn) {
|
|
|
62
62
|
{ role: "user", content: prompt.slice(0, 2000) },
|
|
63
63
|
],
|
|
64
64
|
tools: [],
|
|
65
|
-
signal:
|
|
65
|
+
signal: AbortSignal.timeout(5_000),
|
|
66
66
|
})
|
|
67
67
|
const word = (response.content ?? "").trim().toLowerCase()
|
|
68
68
|
if (word.startsWith("low")) level = "low"
|
package/src/config.mjs
CHANGED
|
@@ -218,6 +218,8 @@ export function loadConfig() {
|
|
|
218
218
|
*/
|
|
219
219
|
export function saveConfig(config) {
|
|
220
220
|
mkdirSync(configDir, { recursive: true })
|
|
221
|
+
// Inject $schema for editor autocompletion/validation (strip on load)
|
|
222
|
+
config.$schema = "https://thincoder.dev/schemas/config.json"
|
|
221
223
|
// 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
|
|
222
224
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
223
225
|
try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
|
package/src/context.mjs
CHANGED
|
@@ -143,7 +143,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
143
143
|
* Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
|
|
144
144
|
* Automatically re-injects task list state after compaction.
|
|
145
145
|
*/
|
|
146
|
-
export async function compressIfNeeded(agent, threshold) {
|
|
146
|
+
export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
147
147
|
const history = agent.history
|
|
148
148
|
// Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
|
|
149
149
|
// Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
|
|
@@ -174,6 +174,8 @@ export async function compressIfNeeded(agent, threshold) {
|
|
|
174
174
|
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
|
|
175
175
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
176
176
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
177
|
+
onToken: callbacks?.onToken,
|
|
178
|
+
onReasoning: callbacks?.onReasoning,
|
|
177
179
|
})
|
|
178
180
|
|
|
179
181
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
package/src/distill.mjs
CHANGED
|
@@ -47,11 +47,26 @@ export async function extractCandidates(provider, transcript) {
|
|
|
47
47
|
const res = await chat(provider, {
|
|
48
48
|
messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
|
|
49
49
|
})
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Balanced-bracket extraction: find the first '[' and track depth through nested
|
|
51
|
+
// brackets (tags arrays, nested objects, etc.) until the matching ']'.
|
|
52
|
+
// Non-greedy regex (/\[[\s\S]*?\]/) stops at the FIRST ']', which is wrong when
|
|
53
|
+
// LLM output contains nested arrays like `"tags": ["a", "b"]`.
|
|
54
|
+
const start = res.content.indexOf("[")
|
|
55
|
+
if (start === -1) return []
|
|
56
|
+
let depth = 0
|
|
57
|
+
let end = -1
|
|
58
|
+
for (let i = start; i < res.content.length; i++) {
|
|
59
|
+
const ch = res.content[i]
|
|
60
|
+
if (ch === "[" && (i === start || res.content[i - 1] !== "\\")) depth++
|
|
61
|
+
else if (ch === "]" && res.content[i - 1] !== "\\") {
|
|
62
|
+
depth--
|
|
63
|
+
if (depth === 0) { end = i + 1; break }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (end === -1) return []
|
|
67
|
+
const jsonText = res.content.slice(start, end)
|
|
53
68
|
try {
|
|
54
|
-
const parsed = JSON.parse(
|
|
69
|
+
const parsed = JSON.parse(jsonText)
|
|
55
70
|
if (!Array.isArray(parsed)) return []
|
|
56
71
|
return parsed.filter((c) => c?.type && c?.title && c?.content)
|
|
57
72
|
} catch {
|
package/src/embedding.mjs
CHANGED
|
@@ -84,7 +84,9 @@ async function requestWithRetry(embedder, input, signal) {
|
|
|
84
84
|
Authorization: `Bearer ${embedder.apiKey}`,
|
|
85
85
|
},
|
|
86
86
|
body: JSON.stringify({ model: embedder.model, input }),
|
|
87
|
-
signal
|
|
87
|
+
signal: signal
|
|
88
|
+
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
|
|
89
|
+
: AbortSignal.timeout(60_000),
|
|
88
90
|
})
|
|
89
91
|
} catch (error) {
|
|
90
92
|
if (error.name === "AbortError") throw error
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
* Notification — generic notification (triggered by agent)
|
|
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
|
+
if (hook.matcher) {
|
|
33
|
+
try {
|
|
34
|
+
if (!new RegExp(hook.matcher).test(ctx.toolName ?? "")) continue
|
|
35
|
+
} catch { /* invalid regex → skip */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allowed = await runOneHook(event, hook, ctx)
|
|
39
|
+
if (hook.action === "block" && !allowed) return false
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runOneHook(event, hook, ctx) {
|
|
45
|
+
const payload = JSON.stringify({
|
|
46
|
+
event,
|
|
47
|
+
toolName: ctx.toolName ?? null,
|
|
48
|
+
toolArgs: ctx.toolArgs ?? null,
|
|
49
|
+
result: ctx.result ?? null,
|
|
50
|
+
error: ctx.error?.message ?? null,
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const timeout = hook.timeout ?? 10_000
|
|
56
|
+
let settled = false
|
|
57
|
+
const done = (code) => {
|
|
58
|
+
if (settled) return
|
|
59
|
+
settled = true
|
|
60
|
+
resolve(code === 0)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let proc
|
|
64
|
+
try {
|
|
65
|
+
if (hook.args?.length) {
|
|
66
|
+
proc = spawn(hook.command, hook.args, {
|
|
67
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
68
|
+
timeout,
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
})
|
|
71
|
+
} else {
|
|
72
|
+
proc = spawn(hook.command, [], {
|
|
73
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
74
|
+
timeout,
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// command not found or spawn failure — don't block, don't crash
|
|
80
|
+
return resolve(true)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
proc.on("error", () => done(0)) // spawn failure → allow
|
|
84
|
+
proc.on("close", (code) => done(code ?? 0))
|
|
85
|
+
proc.on("exit", (code) => done(code ?? 0))
|
|
86
|
+
|
|
87
|
+
// Send payload via stdin
|
|
88
|
+
try { proc.stdin?.end(payload) } catch { /* */ }
|
|
89
|
+
|
|
90
|
+
// Timeout guard
|
|
91
|
+
setTimeout(() => done(0), timeout + 1000)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp/transport-http.mjs — MCP HTTP + SSE transport (Streamable HTTP)
|
|
3
3
|
*/
|
|
4
|
-
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
|
|
4
|
+
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, INIT_TIMEOUT_MS, withTimeout } from "./helpers.mjs"
|
|
5
5
|
|
|
6
6
|
/** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
|
|
7
7
|
export function httpTransport(baseURL, extraHeaders = {}) {
|
|
@@ -56,10 +56,11 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
56
56
|
if (closed) return
|
|
57
57
|
abortController?.abort()
|
|
58
58
|
abortController = new AbortController()
|
|
59
|
+
const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(INIT_TIMEOUT_MS)])
|
|
59
60
|
const resp = await fetch(url, {
|
|
60
61
|
method: "GET",
|
|
61
62
|
headers: { Accept: "text/event-stream", ...extraHeaders },
|
|
62
|
-
signal
|
|
63
|
+
signal,
|
|
63
64
|
})
|
|
64
65
|
if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
|
|
65
66
|
eventSource = parseSSE(resp)
|
package/src/memory/docs.mjs
CHANGED
|
@@ -7,12 +7,11 @@ import { join } from "node:path"
|
|
|
7
7
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
8
|
import { commitAndPush } from "../git/gitmem.mjs"
|
|
9
9
|
import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
10
|
-
import { buildFtsQuery, put, search, putMarkdown } from "./core.mjs"
|
|
10
|
+
import { buildFtsQuery, put, search, putMarkdown, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
11
11
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
12
12
|
import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
|
|
13
13
|
|
|
14
14
|
const DOC_EMBED_BATCH = 64
|
|
15
|
-
const EMBED_TEXT_MAX_LEN = 2000
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
|
package/src/provider/core.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
estimateRequestTokens, rateGate, recordRate,
|
|
11
11
|
} from "./rate.mjs"
|
|
12
12
|
|
|
13
|
-
const FETCH_TIMEOUT_MS =
|
|
13
|
+
const FETCH_TIMEOUT_MS = 600_000
|
|
14
14
|
|
|
15
15
|
/** Create a validated provider config object from raw config */
|
|
16
16
|
export function createProvider(config) {
|
|
@@ -315,6 +315,13 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal })
|
|
|
315
315
|
if (!response.body) throw new Error("No stream response body")
|
|
316
316
|
try {
|
|
317
317
|
for await (const chunk of response.body) {
|
|
318
|
+
// Active signal check: Ctrl+I abort should halt stream immediately, not wait for
|
|
319
|
+
// the underlying fetch stream to propagate the abort (delayed on Windows).
|
|
320
|
+
if (signal?.aborted) {
|
|
321
|
+
const e = new DOMException("The operation was aborted", "AbortError")
|
|
322
|
+
e.reason = signal.reason
|
|
323
|
+
throw e
|
|
324
|
+
}
|
|
318
325
|
buffer += decoder.decode(chunk, { stream: true })
|
|
319
326
|
const lines = buffer.split("\n")
|
|
320
327
|
buffer = lines.pop()
|
package/src/session.mjs
CHANGED
|
@@ -40,7 +40,9 @@ function writeSessionFile(p, data) {
|
|
|
40
40
|
// rename succeeded: clean up temp file
|
|
41
41
|
try { unlinkSync(tmp) } catch {}
|
|
42
42
|
} catch {
|
|
43
|
-
// rename still failed
|
|
43
|
+
// rename still failed — fall back to direct write (non-atomic but data-preserving)
|
|
44
|
+
// p was deleted above; avoid losing both old and new data
|
|
45
|
+
writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -161,6 +163,7 @@ export function saveSession(agent, display) {
|
|
|
161
163
|
planMode: agent.planMode ?? false,
|
|
162
164
|
autoApprove: agent.autoApprove ?? false,
|
|
163
165
|
goal: agent.goal ?? null,
|
|
166
|
+
advisor: agent.config?.advisor ?? null,
|
|
164
167
|
pendingReminders: agent._pendingReminders ?? [],
|
|
165
168
|
sessionStart: agent._sessionStart ?? null,
|
|
166
169
|
}
|
|
@@ -214,6 +217,9 @@ export function applySession(agent, data) {
|
|
|
214
217
|
agent.goal = data.goal ?? null
|
|
215
218
|
agent._pendingReminders = data.pendingReminders ?? []
|
|
216
219
|
agent._sessionStart = data.sessionStart ?? null
|
|
220
|
+
if (data.advisor) {
|
|
221
|
+
agent.config.advisor = { ...data.advisor }
|
|
222
|
+
}
|
|
217
223
|
// Reset stall/compaction state on session switch
|
|
218
224
|
agent._compressFailures = 0
|
|
219
225
|
agent._verifyRetries = 0
|
|
@@ -234,7 +240,7 @@ export function clearSession(cwd) {
|
|
|
234
240
|
try {
|
|
235
241
|
archiveCurrent(cwd)
|
|
236
242
|
const p = sessionPath(cwd)
|
|
237
|
-
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
|
|
243
|
+
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null })
|
|
238
244
|
} catch {
|
|
239
245
|
// Can't clear, oh well — next save will overwrite
|
|
240
246
|
}
|
package/src/tools/checklist.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
Manage the task checklist in .thincoder/checklist.md. Use at these points: after requirements are confirmed — add one entry per requirement point; when starting work — mark in_progress; when verified complete — mark done. Checklist entries map to requirement/design points — project-level tracking across sessions. For in-session subtask breakdown of a single checklist item, use the `task` tool instead. Completed items are auto-archived to .thincoder/checklist-done.md.
|
|
2
2
|
|
|
3
|
+
Items support tree hierarchy via indentation (2 spaces per level) and auto-assigned IDs (T1, T1.1, T1.2.1). Use the `parent` parameter to add a child under an existing task.
|
|
4
|
+
|
|
3
5
|
Parameters:
|
|
4
6
|
- action: "add" | "mark" | "list"
|
|
5
7
|
- item: text for new item (with "add")
|
|
6
|
-
- index: 1-based index (with "mark")
|
|
8
|
+
- index: 1-based index (with "mark")
|
|
7
9
|
- status: "pending" | "in_progress" | "done" (with "mark")
|
|
10
|
+
- parent: parent task ID for hierarchical tasks, e.g. "T1" (with "add")
|