thincoder 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +29 -6
  2. package/package.json +3 -3
  3. package/src/advisor/history.mjs +112 -0
  4. package/src/advisor/messages.mjs +182 -0
  5. package/src/advisor/repos.mjs +133 -0
  6. package/src/advisor/run.mjs +346 -0
  7. package/src/advisor.mjs +109 -509
  8. package/src/agent/completion.mjs +134 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +95 -6
  12. package/src/agent-tools/advisor.mjs +159 -12
  13. package/src/agent-tools/eng.mjs +64 -0
  14. package/src/agent-tools/subagent.mjs +73 -3
  15. package/src/agent-tools/task.mjs +45 -6
  16. package/src/agent-tools/verify.mjs +18 -0
  17. package/src/agent-tools.mjs +1 -0
  18. package/src/agent.mjs +152 -161
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +34 -4
  22. package/src/context.mjs +47 -13
  23. package/src/generate-title.mjs +44 -0
  24. package/src/prompts/advisor-design.md +43 -0
  25. package/src/prompts/advisor-round1.md +11 -4
  26. package/src/prompts/advisor-round2.md +12 -7
  27. package/src/prompts/advisor-round3.md +11 -6
  28. package/src/prompts/coder.md +9 -3
  29. package/src/prompts/discipline.md +12 -96
  30. package/src/prompts/eng-coder.md +34 -0
  31. package/src/prompts/engineering-sub.md +12 -0
  32. package/src/prompts/engineering.md +96 -0
  33. package/src/prompts/main.md +1 -1
  34. package/src/prompts/methodology-template.md +39 -0
  35. package/src/prompts/plan.md +2 -2
  36. package/src/prompts/system.md +43 -61
  37. package/src/provider/core.mjs +58 -2
  38. package/src/session.mjs +291 -94
  39. package/src/skills.mjs +48 -15
  40. package/src/tools/apply_patch.md +1 -1
  41. package/src/tools/checklist.mjs +4 -3
  42. package/src/tools/codemode.mjs +23 -11
  43. package/src/tools/delete.md +1 -0
  44. package/src/tools/edit.md +1 -1
  45. package/src/tools/execute.md +5 -0
  46. package/src/tools/file.mjs +4 -0
  47. package/src/tools/git.md +15 -0
  48. package/src/tools/git.mjs +1 -6
  49. package/src/tools/lint.md +8 -0
  50. package/src/tools/linter.mjs +1 -5
  51. package/src/tools/lsp.md +7 -0
  52. package/src/tools/lsp.mjs +8 -9
  53. package/src/tools/patch.mjs +1 -29
  54. package/src/tools/read_image.md +5 -1
  55. package/src/tools/system.mjs +1 -1
  56. package/src/tools/web.mjs +3 -3
  57. package/src/tui/agent-turn.mjs +184 -66
  58. package/src/tui/ansi.mjs +4 -0
  59. package/src/tui/clipboard.mjs +9 -0
  60. package/src/tui/cmd-config.mjs +14 -26
  61. package/src/tui/cmd-eng.mjs +44 -0
  62. package/src/tui/cmd-exit.mjs +1 -1
  63. package/src/tui/cmd-fold.mjs +3 -4
  64. package/src/tui/cmd-model.mjs +11 -6
  65. package/src/tui/cmd-new.mjs +5 -5
  66. package/src/tui/cmd-session.mjs +21 -11
  67. package/src/tui/cmd-think.mjs +1 -0
  68. package/src/tui/index.mjs +20 -9
  69. package/src/tui/key-handler.mjs +177 -9
  70. package/src/tui/layout.mjs +5 -5
  71. package/src/tui/markdown.mjs +52 -0
  72. package/src/tui/pickers.mjs +190 -45
  73. package/src/tui/render-conversation.mjs +54 -13
  74. package/src/tui/render-frame.mjs +39 -12
  75. package/src/tui/render-loop.mjs +2 -1
  76. package/src/tui/render.mjs +13 -7
  77. package/src/tui/slash-commands.mjs +11 -7
  78. package/src/tui/startup.mjs +4 -3
  79. package/src/tui/wizard.mjs +3 -0
  80. package/src/tools/checkpoint.md +0 -15
  81. package/src/tools/git_diff.md +0 -11
  82. package/src/tools/git_log.md +0 -10
  83. package/src/tools/git_status.md +0 -8
  84. package/src/tools/linter.md +0 -13
  85. package/src/tools/syntax_check.md +0 -10
package/src/session.mjs CHANGED
@@ -1,31 +1,71 @@
1
1
  /**
2
- * session.mjs — session persistence
3
- * Each project (keyed by cwd hash) keeps up to 5 session slots, rotated by last-access time.
4
- * Two recovery needs stored separately: agent recovery (history) needs context continuity, user recovery (display) needs WYSIWYG.
2
+ * session.mjs — session persistence (slot-based model)
3
+ * Each project (keyed by cwd hash) keeps unlimited session slots.
4
+ * Every session lives in a numbered slot; the manifest tracks which slot is active.
5
+ * There is no separate "current" file — the active slot IS the current session.
5
6
  *
6
- * File layout: {hash}.json (current), {hash}.json.1~5 (slots), {hash}.json.manifest (slot metadata)
7
+ * File layout: {hash}.json.N (slots), {hash}.json.manifest (slot metadata + active pointer).
8
+ * Legacy {hash}.json is migrated to a slot on first access.
7
9
  */
8
10
 
9
11
  import { createHash } from "node:crypto"
10
- import { mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, unlinkSync, existsSync } from "node:fs"
12
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs"
11
13
  import { join, dirname } from "node:path"
14
+ import { execSync } from "node:child_process"
12
15
  import { configDir } from "./config.mjs"
13
16
 
14
- const MAX_SLOTS = 5
15
- const CWD_HASH_LEN = 12
17
+ let currentSessionId = null
16
18
 
17
- /** Derive session file path from cwd hash */
19
+ /** Generate unique session ID for this process */
20
+ export function getSessionId() {
21
+ if (!currentSessionId) {
22
+ currentSessionId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
23
+ }
24
+ return currentSessionId
25
+ }
26
+
27
+ /** Normalize cwd for hashing: uppercase Windows drive letter so both ends
28
+ * (CLI's process.cwd() vs VS Code's uri.fsPath, which lowercases it) agree. */
29
+ export function normalizeCwd(cwd) {
30
+ return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
31
+ }
32
+
33
+ /** Full sha1 hex (40 chars), not truncated. Shared contract with the VS Code extension. */
34
+ function cwdHash(cwd) {
35
+ return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
36
+ }
37
+
38
+ /** One-time migration: rename legacy 12-char-hash session files to the full 40-char hash.
39
+ * Idempotent; runs on first access per cwd. */
40
+ function migrateHashLength(cwd, fullHash) {
41
+ const dir = join(configDir, "sessions")
42
+ const legacyBase = join(dir, `${fullHash.slice(0, 12)}.json`)
43
+ if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) return
44
+ const newBase = join(dir, `${fullHash}.json`)
45
+ try {
46
+ for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
47
+ const from = legacyBase + suffix
48
+ if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
49
+ }
50
+ } catch { /* best-effort; leave files in place on failure */ }
51
+ }
52
+
53
+ /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
18
54
  export function sessionPath(cwd) {
19
- const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
55
+ const hash = cwdHash(cwd)
56
+ migrateHashLength(cwd, hash)
20
57
  return join(configDir, "sessions", `${hash}.json`)
21
58
  }
22
59
 
23
60
  function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
24
61
  function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
25
62
 
26
- /** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash.
27
- * rename is atomic on POSIX; on Windows with existing target, Node 24 uses MoveFileExW+REPLACE_EXISTING for atomic replace.
28
- * Some older Windows filesystems may throw EPERM — retry once. */
63
+ /** Path to the active slot's file */
64
+ export function activePath(cwd) {
65
+ return slotPath(cwd, activeSlot(cwd))
66
+ }
67
+
68
+ /** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash. */
29
69
  function writeSessionFile(p, data) {
30
70
  mkdirSync(dirname(p), { recursive: true })
31
71
  const tmp = `${p}.tmp`
@@ -33,15 +73,11 @@ function writeSessionFile(p, data) {
33
73
  try {
34
74
  renameSync(tmp, p)
35
75
  } catch {
36
- // Windows: rename may fail due to antivirus lock / network drive contention — delete target and retry
37
76
  try { unlinkSync(p) } catch {}
38
77
  try {
39
78
  renameSync(tmp, p)
40
- // rename succeeded: clean up temp file
41
79
  try { unlinkSync(tmp) } catch {}
42
80
  } catch {
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
81
  writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
46
82
  }
47
83
  }
@@ -49,86 +85,181 @@ function writeSessionFile(p, data) {
49
85
 
50
86
  // ========== slot management ==========
51
87
 
88
+ /** Normalize slot metadata: old format is a raw timestamp number; new format is { ts, ... } object */
89
+ function slotMetaTs(v) { return typeof v === "number" ? v : (v?.ts ?? 0) }
90
+
91
+ function slotCmp(a, b) { return slotMetaTs(a[1]) - slotMetaTs(b[1]) }
92
+
93
+ /** Detect a genuine user message (excludes system-reminder injected messages) */
94
+ function isRealUserMsg(m) {
95
+ return m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
96
+ }
97
+
98
+ /** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
99
+ function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
100
+ const userMsgs = history.filter(isRealUserMsg)
101
+ const first = userMsgs[0]?.content ?? ""
102
+ return {
103
+ messageCount: history.length,
104
+ turnCount: userMsgs.length,
105
+ firstMessage: first.slice(0, 80),
106
+ activeProvider: activeProvider ?? "",
107
+ updatedAt: updatedAt ?? Date.now(),
108
+ title,
109
+ }
110
+ }
111
+
112
+ /** Extract preview summary from session data for manifest storage (with current timestamp) */
113
+ function slotDigest(data) {
114
+ const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
115
+ return { ts: Date.now(), ...meta }
116
+ }
117
+
52
118
  function loadManifest(cwd) {
53
119
  try {
54
120
  const p = manifestPath(cwd)
55
- if (!existsSync(p)) return { slots: {} }
56
- return JSON.parse(readFileSync(p, "utf8"))
57
- } catch { return { slots: {} } }
121
+ if (!existsSync(p)) return { slots: {}, sessionId: null }
122
+ const m = JSON.parse(readFileSync(p, "utf8"))
123
+ if (!m.sessionId) m.sessionId = null
124
+ return m
125
+ } catch { return { slots: {}, sessionId: null } }
58
126
  }
59
127
 
60
128
  function saveManifest(cwd, m) {
129
+ m.sessionId = getSessionId()
61
130
  writeSessionFile(manifestPath(cwd), m)
62
131
  }
63
132
 
64
- /** Archive current session to a free slot — evict oldest if full; exclude specifies a slot that must not be evicted (switchToSlot target) */
65
- export function archiveCurrent(cwd, { exclude } = {}) {
66
- const src = sessionPath(cwd)
67
- if (!existsSync(src)) return
68
- const m = loadManifest(cwd)
133
+ /**
134
+ * Ensure an active slot exists in the manifest, migrating legacy data if needed.
135
+ * Called by activeSlot() — idempotent, safe to call repeatedly.
136
+ */
137
+ /**
138
+ * Claim a slot for this process and set it as active. Idempotent.
139
+ * Preference order:
140
+ * 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
141
+ * 2. Any slot that is unowned or owned by a dead process (reclaim).
142
+ * 3. A brand-new slot when all are owned by live processes.
143
+ * The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
144
+ * see which slots are taken and avoid them.
145
+ */
146
+ function ensureActive(cwd, m) {
147
+ const mySessionId = getSessionId()
148
+ if (!m.slotSessions) m.slotSessions = {}
149
+
150
+ // Already own the active slot — nothing to do.
151
+ if (m.active && m.slotSessions[m.active] === mySessionId) return
69
152
 
70
- let slot
71
- // Only count numeric-keyed slots, exclude legacy non-numeric keys like _currentName
72
- const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
73
- if (entries.length < MAX_SLOTS) {
74
- slot = 1
75
- while (m.slots[slot]) slot++
76
- } else {
77
- const candidates = entries.filter(([n]) => Number(n) !== exclude)
78
- slot = Number((candidates.length ? candidates : entries).sort((a, b) => a[1] - b[1])[0][0])
153
+ const isFree = (slot) => {
154
+ const owner = m.slotSessions[slot]
155
+ if (!owner || owner === mySessionId) return true
156
+ return !isProcessAlive(parseInt(owner.split('-')[0]))
79
157
  }
80
158
 
81
- const dst = slotPath(cwd, slot)
82
- // Copy (rename would lose current); use atomic write to prevent truncated JSON in archive from mid-crash
83
- let data
84
- try {
85
- data = JSON.parse(readFileSync(src, "utf8"))
86
- } catch {
87
- // Session file corrupted, abandon archive; next save will overwrite
159
+ // 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
160
+ if (m.active && m.slots[m.active] && isFree(m.active)) {
161
+ m.slotSessions[m.active] = mySessionId
162
+ saveManifest(cwd, m)
88
163
  return
89
164
  }
90
- writeSessionFile(dst, data)
91
- m.slots[slot] = Date.now()
92
- delete m.slots._currentName
165
+
166
+ // 2. Otherwise claim the first slot that is free.
167
+ const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
168
+ for (const slot of allSlots) {
169
+ if (isFree(slot)) {
170
+ m.active = slot
171
+ m.slotSessions[slot] = mySessionId
172
+ saveManifest(cwd, m)
173
+ return
174
+ }
175
+ }
176
+
177
+ // 3. All slots owned by live processes — allocate a new one (no limit).
178
+ const newSlot = allSlots.length > 0 ? Math.max(...allSlots) + 1 : 1
179
+ m.active = newSlot
180
+ m.slotSessions[newSlot] = mySessionId
93
181
  saveManifest(cwd, m)
94
- return slot
95
182
  }
96
183
 
97
- /** List all archive slots, newest first */
98
- export function listSlots(cwd) {
99
- const m = loadManifest(cwd)
100
- return Object.entries(m.slots)
101
- .map(([n, ts]) => ({ slot: Number(n), timestamp: ts, date: new Date(ts).toLocaleString() }))
102
- .sort((a, b) => b.timestamp - a.timestamp)
184
+ /**
185
+ * Check if a process with given PID is still alive.
186
+ * Returns false if process doesn't exist or we can't determine.
187
+ */
188
+ function isProcessAlive(pid) {
189
+ if (!pid || isNaN(pid)) return false
190
+ try {
191
+ // On Windows: tasklist /FI "PID eq <pid>" /NH
192
+ // On Unix: kill(pid, 0) or check /proc/<pid>
193
+ if (process.platform === 'win32') {
194
+ const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', stdio: 'pipe' })
195
+ return output.includes(String(pid))
196
+ } else {
197
+ // Unix: try to send signal 0 (doesn't kill, just checks)
198
+ process.kill(pid, 0)
199
+ return true
200
+ }
201
+ } catch {
202
+ return false
203
+ }
103
204
  }
104
205
 
105
- /** Switch to a specific slot: archive current copy slot file to current → return recovered data (null on failure) */
106
- export function switchToSlot(cwd, slot) {
206
+ /** Return the active slot number for this process, claiming one if necessary */
207
+ export function activeSlot(cwd) {
107
208
  const m = loadManifest(cwd)
108
- if (!m.slots[slot]) return null
109
-
110
- // Archive current (internally writes manifest; our `m` is stale after, must reload)
111
- // When full, exclude target slot: otherwise oldest=target, archive would overwrite target then copy back, permanently losing the target session
112
- archiveCurrent(cwd, { exclude: slot })
209
+ ensureActive(cwd, m)
210
+ return m.active
211
+ }
113
212
 
114
- // Slot file current (copy+unlink, not rename: Windows rename on existing target throws EPERM)
115
- const src = slotPath(cwd, slot)
116
- const dst = sessionPath(cwd)
117
- if (!existsSync(src)) return null
213
+ /** Lazy-load slot metadata from slot file (for old-format manifest entries that lack metadata) */
214
+ function loadSlotMeta(cwd, slot, v) {
215
+ if (typeof v === "object" && v !== null && "ts" in v) return v
216
+ const ts = typeof v === "number" ? v : 0
118
217
  try {
119
- try { unlinkSync(dst) } catch { /* doesn't exist, that's fine */ }
120
- copyFileSync(src, dst)
121
- unlinkSync(src)
218
+ const p = slotPath(cwd, slot)
219
+ if (!existsSync(p)) return { ts }
220
+ const data = JSON.parse(readFileSync(p, "utf8"))
221
+ const history = data.history ?? []
222
+ const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
223
+ return { ts, ...meta }
122
224
  } catch {
123
- // File operations failed (disk full / permissions / lock), abandon switch
124
- return null
225
+ return { ts }
125
226
  }
227
+ }
126
228
 
127
- // Reload manifest (archiveCurrent modified it)
128
- const m2 = loadManifest(cwd)
129
- delete m2.slots[slot]
130
- saveManifest(cwd, m2)
229
+ /** List all slots, newest first. Includes isActive flag. */
230
+ export function listSlots(cwd) {
231
+ const m = loadManifest(cwd)
232
+ const active = m.active ?? activeSlot(cwd)
233
+ return Object.entries(m.slots)
234
+ .filter(([n]) => /^\d+$/.test(n))
235
+ .map(([n, v]) => {
236
+ const meta = loadSlotMeta(cwd, Number(n), v)
237
+ return {
238
+ slot: Number(n),
239
+ isActive: Number(n) === active,
240
+ timestamp: meta.ts,
241
+ date: new Date(meta.ts).toLocaleString(),
242
+ messageCount: meta.messageCount ?? 0,
243
+ turnCount: meta.turnCount ?? 0,
244
+ firstMessage: meta.firstMessage ?? "",
245
+ activeProvider: meta.activeProvider ?? "",
246
+ updatedAt: meta.updatedAt ?? meta.ts,
247
+ updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
248
+ title: meta.title ?? "",
249
+ }
250
+ })
251
+ .sort((a, b) => b.updatedAt - a.updatedAt)
252
+ }
131
253
 
254
+ /**
255
+ * Switch active slot. No file copying — just change the pointer in the manifest.
256
+ * Returns the loaded session data (null if slot doesn't exist).
257
+ */
258
+ export function switchToSlot(cwd, slot) {
259
+ const m = loadManifest(cwd)
260
+ if (!m.slots[slot]) return null
261
+ m.active = slot
262
+ saveManifest(cwd, m)
132
263
  return loadSession(cwd)
133
264
  }
134
265
 
@@ -149,28 +280,47 @@ function isLegacyTransient(m) {
149
280
 
150
281
  // ========== core read/write ==========
151
282
 
152
- /** Save agent state and display lines to the session file (atomic write) */
283
+ /** Save agent state and display lines to the active slot file (atomic write) */
153
284
  export function saveSession(agent, display) {
154
- const history = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
285
+ // _fullHistory is written at the source via pushReal — no flush needed here.
286
+ // history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
287
+ // contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
288
+ const history = (agent._fullHistory ?? agent.history).filter((m) => !m.transient && !isLegacyTransient(m))
289
+ const contextHistory = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
155
290
  const data = {
156
291
  version: 2,
157
292
  cwd: agent.cwd,
293
+ title: agent.title ?? "",
158
294
  activeProvider: agent.activeProvider ?? agent.provider?.name,
295
+ activeModel: agent.activeModel ?? null,
159
296
  updatedAt: Date.now(),
160
297
  history,
298
+ contextHistory,
161
299
  display: display ?? [],
162
300
  tasks: agent.tasks ?? [],
163
301
  planMode: agent.planMode ?? false,
164
302
  autoApprove: agent.autoApprove ?? false,
303
+ engineering: agent.config?.agent?.engineering ?? false,
304
+ engDesignToken: agent._engDesignToken ?? null,
165
305
  goal: agent.goal ?? null,
166
306
  advisor: agent.config?.advisor ?? null,
167
307
  pendingReminders: agent._pendingReminders ?? [],
168
308
  sessionStart: agent._sessionStart ?? null,
169
309
  }
170
- writeSessionFile(sessionPath(agent.cwd), data)
310
+ const slot = activeSlot(agent.cwd)
311
+ writeSessionFile(slotPath(agent.cwd, slot), data)
312
+ // Update slot metadata in manifest
313
+ try {
314
+ const m = loadManifest(agent.cwd)
315
+ m.slots[slot] = slotDigest(data)
316
+ saveManifest(agent.cwd, m)
317
+ } catch (e) {
318
+ // Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
319
+ console.error(`[session] manifest metadata update failed for slot ${slot}: ${e.message}`)
320
+ }
171
321
  }
172
322
 
173
- /** Load session data from disk; returns null if missing, corrupted, or version mismatch */
323
+ /** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
174
324
  export function loadSession(cwd) {
175
325
  const tryLoad = (p) => {
176
326
  try {
@@ -189,34 +339,56 @@ export function loadSession(cwd) {
189
339
  return { _error: e }
190
340
  }
191
341
  }
192
- const p = sessionPath(cwd)
342
+
343
+ // Try the active slot first (post-migration, legacy file may be stale)
344
+ const slot = activeSlot(cwd)
345
+ const p = slotPath(cwd, slot)
193
346
  let result = tryLoad(p)
347
+ if (result && !result._error) return result
194
348
  if (result?._error) {
195
- // Main file corrupted try the .tmp fallback from a failed atomic write
196
- console.error(`[session] failed to load ${p}: ${result._error.message}. Trying .tmp fallback...`)
349
+ console.error(`[session] failed to load slot ${slot}: ${result._error.message}. Trying .tmp fallback...`)
197
350
  const tmpResult = tryLoad(`${p}.tmp`)
198
351
  if (tmpResult && !tmpResult._error) {
199
352
  console.error(`[session] recovered from .tmp fallback`)
200
- result = tmpResult
201
- result._recovered = true
202
- } else {
203
- console.error(`[session] .tmp fallback also failed — session lost. Backing up corrupted file as .corrupted.`)
204
- try { renameSync(p, `${p}.corrupted`) } catch (e) { console.error(`[session] rename to .corrupted also failed: ${e.message}`) }
205
- return null
353
+ tmpResult._recovered = true
354
+ return tmpResult
206
355
  }
356
+ console.error(`[session] .tmp fallback also failed — session lost.`)
357
+ try { renameSync(p, `${p}.corrupted`) } catch {}
358
+ }
359
+
360
+ // Fallback: try the legacy current file (pre-migration, or migration failed to clean up)
361
+ const legacy = sessionPath(cwd)
362
+ result = tryLoad(legacy)
363
+ if (result && !result._error) {
364
+ return result
365
+ }
366
+ if (result?._error) {
367
+ console.error(`[session] failed to load legacy ${legacy}: ${result._error.message}`)
368
+ try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
207
369
  }
208
- return result && !result._error ? result : null
370
+
371
+ return null
209
372
  }
210
373
 
211
374
  /** Apply loaded session data onto an agent object; returns true if provider was switched */
212
375
  export function applySession(agent, data) {
213
- agent.history = data.history
376
+ // data.history is the FULL never-compacted record (human line); data.contextHistory is the
377
+ // (possibly compacted) machine line. Restore each line from its own source — the machine
378
+ // context keeps its compaction savings across resume. Legacy files without contextHistory
379
+ // fall back to seeding the machine line from the full history (it re-compacts when needed).
380
+ const full = Array.isArray(data.history) ? data.history : []
381
+ const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
382
+ agent._fullHistory = [...full]
383
+ agent.history = [...machine]
384
+ agent.title = data.title ?? ""
214
385
  agent.tasks = data.tasks ?? []
215
386
  agent.planMode = data.planMode ?? false
216
387
  agent.autoApprove = data.autoApprove ?? false
217
388
  agent.goal = data.goal ?? null
218
389
  agent._pendingReminders = data.pendingReminders ?? []
219
390
  agent._sessionStart = data.sessionStart ?? null
391
+ agent._engDesignToken = data.engDesignToken ?? null
220
392
  if (data.advisor) {
221
393
  agent.config.advisor = { ...data.advisor }
222
394
  }
@@ -229,19 +401,44 @@ export function applySession(agent, data) {
229
401
  if (p) {
230
402
  agent.provider = { ...p }
231
403
  agent.activeProvider = p.name
404
+ agent.activeModel = data.activeModel ?? null
405
+ if (agent.activeModel) agent.provider.model = agent.activeModel
232
406
  return true
233
407
  }
408
+ } else if (data.activeModel != null) {
409
+ // Same provider, different model
410
+ agent.activeModel = data.activeModel
411
+ if (agent.activeModel && agent.provider) agent.provider.model = agent.activeModel
412
+ } else if (data.activeProvider && data.activeProvider === agent.activeProvider) {
413
+ // Same provider, session has no activeModel → clear stale override
414
+ agent.activeModel = null
415
+ if (agent.provider) {
416
+ const p = agent.providers?.find((pr) => pr.name === agent.activeProvider)
417
+ if (p) agent.provider.model = p.model
418
+ }
234
419
  }
235
420
  return false
236
421
  }
237
422
 
238
- /** Archive current session and reset the session file to empty state */
239
- export function clearSession(cwd) {
240
- try {
241
- archiveCurrent(cwd)
242
- const p = sessionPath(cwd)
243
- writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null })
244
- } catch {
245
- // Can't clear, oh well next save will overwrite
246
- }
423
+ /**
424
+ * Create a new session slot: allocate a free slot number,
425
+ * write an empty session, and mark it as the active slot.
426
+ * No limit on the number of sessions.
427
+ */
428
+ export function newSession(cwd) {
429
+ const m = loadManifest(cwd)
430
+ // Ensure active is set (migrate if needed)
431
+ if (!m.active) ensureActive(cwd, m)
432
+
433
+ // Find next available slot number
434
+ let slot = 1
435
+ while (m.slots[slot]) slot++
436
+
437
+ // Write empty session
438
+ const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
439
+ writeSessionFile(slotPath(cwd, slot), data)
440
+ m.slots[slot] = slotDigest(data)
441
+ m.active = slot
442
+ saveManifest(cwd, m)
443
+ return slot
247
444
  }
package/src/skills.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { readFile, readdir, stat } from "node:fs/promises"
13
13
  import { join } from "node:path"
14
+ import { homedir } from "node:os"
14
15
 
15
16
  /** Valid skill name pattern (alphanumeric + hyphens/underscores) */
16
17
  const NAME_RE = /^[a-zA-Z0-9_-]+$/
@@ -42,13 +43,12 @@ async function tryReadSkill(dir, name, filePath) {
42
43
  }
43
44
 
44
45
  /**
45
- * Scan .thincoder/skills/ directory, return skill list.
46
+ * Scan a single skills directory, return skill list.
46
47
  * Supports flat .md files and subdirectories with SKILL.md inside.
47
48
  * Each skill: { name, path, description } — name derived from filename or directory.
48
49
  * Returns empty array if directory is missing or empty.
49
50
  */
50
- export async function loadSkills(cwd) {
51
- const dir = join(cwd, ".thincoder", "skills")
51
+ async function loadSkillsFromDir(dir) {
52
52
  let entries
53
53
  try {
54
54
  entries = await readdir(dir, { withFileTypes: true })
@@ -79,6 +79,31 @@ export async function loadSkills(cwd) {
79
79
  return skills
80
80
  }
81
81
 
82
+ /**
83
+ * Scan both project-level and user-level skills directories.
84
+ * Project-level skills (cwd/.thincoder/skills/) take priority over user-level (~/.thincoder/skills/).
85
+ * Returns merged skill list with project-level skills first.
86
+ */
87
+ export async function loadSkills(cwd) {
88
+ const projectDir = join(cwd, ".thincoder", "skills")
89
+ const userDir = join(homedir(), ".thincoder", "skills")
90
+
91
+ // Load project-level skills first (higher priority)
92
+ const projectSkills = await loadSkillsFromDir(projectDir)
93
+ const added = new Set(projectSkills.map(s => s.name))
94
+
95
+ // Load user-level skills, skipping duplicates
96
+ const userSkills = await loadSkillsFromDir(userDir)
97
+ for (const skill of userSkills) {
98
+ if (!added.has(skill.name)) {
99
+ projectSkills.push(skill)
100
+ added.add(skill.name)
101
+ }
102
+ }
103
+
104
+ return projectSkills
105
+ }
106
+
82
107
  /**
83
108
  * Generate skill listing text for system prompt injection.
84
109
  * At most 3 (small footprint); overflow marked "... and N more".
@@ -94,23 +119,31 @@ export function formatSkillListing(skills) {
94
119
 
95
120
  /**
96
121
  * Read the full content of a specific skill file.
97
- * Tries subdirectory format (name/SKILL.md) first, then flat format (name.md).
122
+ * Tries project-level directory first, then user-level directory.
123
+ * For each directory, tries subdirectory format (name/SKILL.md) first, then flat format (name.md).
98
124
  * Returns text, or null if not found.
99
125
  */
100
126
  export async function readSkill(cwd, name) {
101
127
  if (!NAME_RE.test(name)) return null
102
128
 
103
- // Try subdirectory format: name/SKILL.md
104
- try {
105
- const p = join(cwd, ".thincoder", "skills", name, "SKILL.md")
106
- return await readFile(p, "utf8")
107
- } catch { /* not found, try flat */ }
129
+ const dirs = [
130
+ join(cwd, ".thincoder", "skills"),
131
+ join(homedir(), ".thincoder", "skills")
132
+ ]
108
133
 
109
- // Fallback to flat format: name.md
110
- try {
111
- const p = join(cwd, ".thincoder", "skills", `${name}.md`)
112
- return await readFile(p, "utf8")
113
- } catch {
114
- return null
134
+ for (const baseDir of dirs) {
135
+ // Try subdirectory format: name/SKILL.md
136
+ try {
137
+ const p = join(baseDir, name, "SKILL.md")
138
+ return await readFile(p, "utf8")
139
+ } catch { /* not found, try flat */ }
140
+
141
+ // Fallback to flat format: name.md
142
+ try {
143
+ const p = join(baseDir, `${name}.md`)
144
+ return await readFile(p, "utf8")
145
+ } catch { /* not found, try next directory */ }
115
146
  }
147
+
148
+ return null
116
149
  }
@@ -8,4 +8,4 @@ Notes:
8
8
  - Hunks are located by their context/removed lines, not line numbers — but the context must match the file EXACTLY. Read the files first and generate the patch from actual content
9
9
  - If a hunk's context matches multiple locations it is rejected — add more surrounding context lines
10
10
  - Deleting files is not supported — use the delete tool
11
- - For single-file small edits, edit is simpler; for full rewrites, write is simpler
11
+ - For single-file edits, edit is simpler; for full rewrites, write is simpler
@@ -31,14 +31,15 @@ function parse(filePath) {
31
31
  const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
32
  const text = m[3].trim()
33
33
 
34
- // Extract explicit ID if present (e.g. "T1:", "T1.1:")
35
- const idMatch = text.match(/^(T[\d.]+):/)
34
+ // Extract explicit ID if present (e.g. "T1:", "T1.1:") — strip it from the text
35
+ // so write() doesn't re-prepend it (round-trip would otherwise accumulate "T1: T1: ...")
36
+ const idMatch = text.match(/^(T[\d.]+):\s*/)
36
37
  const node = {
37
38
  id: idMatch ? idMatch[1] : null,
38
39
  index: flatIdx,
39
40
  depth,
40
41
  status,
41
- text,
42
+ text: idMatch ? text.slice(idMatch[0].length) : text,
42
43
  children: [],
43
44
  }
44
45