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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.8.12",
3
+ "version": "0.9.0",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
- : "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)"
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
- if (item.tool.outputPanel) callbacks.setupOutputPanel?.(item.toolCall.name)
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
  }
@@ -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,
@@ -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 marked 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.`
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
- // The abort may fire before the SSE stream starts (during rate gate or HTTP request).
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 retry.
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
- continue
181
+ throw Object.assign(new Error("User interrupted"), { name: "AbortError" })
183
182
  }
184
183
 
185
184
  if (response.usage) {
@@ -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: new AbortController().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
- // Non-greedy match first JSON array (greedy [\s\S]* would eat across multiple arrays including interstitial text)
51
- const match = res.content.match(/\[[\s\S]*?\]/)
52
- if (!match) return []
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(match[0])
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: abortController.signal,
63
+ signal,
63
64
  })
64
65
  if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
65
66
  eventSource = parseSSE(resp)
@@ -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.
@@ -10,7 +10,7 @@ import {
10
10
  estimateRequestTokens, rateGate, recordRate,
11
11
  } from "./rate.mjs"
12
12
 
13
- const FETCH_TIMEOUT_MS = 120000
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: keep tmp as fallback data (next read prefers main file; if missing, tmp is at least there)
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
  }
@@ -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")