thincoder 0.12.1 → 0.12.3

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 (77) hide show
  1. package/README.md +18 -5
  2. package/package.json +1 -1
  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 +119 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +93 -5
  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 +110 -150
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +22 -4
  22. package/src/prompts/advisor-design.md +43 -0
  23. package/src/prompts/advisor-round1.md +11 -4
  24. package/src/prompts/advisor-round2.md +12 -7
  25. package/src/prompts/advisor-round3.md +11 -6
  26. package/src/prompts/coder.md +9 -3
  27. package/src/prompts/discipline.md +12 -96
  28. package/src/prompts/eng-coder.md +34 -0
  29. package/src/prompts/engineering-sub.md +12 -0
  30. package/src/prompts/engineering.md +96 -0
  31. package/src/prompts/main.md +1 -1
  32. package/src/prompts/methodology-template.md +39 -0
  33. package/src/prompts/plan.md +2 -2
  34. package/src/prompts/system.md +43 -61
  35. package/src/session.mjs +270 -89
  36. package/src/skills.mjs +48 -15
  37. package/src/tools/apply_patch.md +1 -1
  38. package/src/tools/codemode.mjs +10 -4
  39. package/src/tools/delete.md +1 -0
  40. package/src/tools/edit.md +1 -1
  41. package/src/tools/execute.md +5 -0
  42. package/src/tools/file.mjs +4 -0
  43. package/src/tools/git.md +15 -0
  44. package/src/tools/git.mjs +1 -6
  45. package/src/tools/lint.md +8 -0
  46. package/src/tools/linter.mjs +1 -5
  47. package/src/tools/lsp.md +7 -0
  48. package/src/tools/lsp.mjs +8 -9
  49. package/src/tools/patch.mjs +1 -29
  50. package/src/tools/read_image.md +5 -1
  51. package/src/tools/system.mjs +1 -1
  52. package/src/tools/web.mjs +3 -3
  53. package/src/tui/agent-turn.mjs +169 -66
  54. package/src/tui/cmd-config.mjs +12 -0
  55. package/src/tui/cmd-eng.mjs +44 -0
  56. package/src/tui/cmd-exit.mjs +6 -16
  57. package/src/tui/cmd-fold.mjs +3 -4
  58. package/src/tui/cmd-model.mjs +11 -6
  59. package/src/tui/cmd-new.mjs +5 -5
  60. package/src/tui/cmd-session.mjs +21 -11
  61. package/src/tui/cmd-think.mjs +1 -0
  62. package/src/tui/index.mjs +7 -6
  63. package/src/tui/key-handler.mjs +132 -4
  64. package/src/tui/layout.mjs +5 -5
  65. package/src/tui/pickers.mjs +184 -44
  66. package/src/tui/render-conversation.mjs +49 -11
  67. package/src/tui/render-frame.mjs +38 -12
  68. package/src/tui/render-loop.mjs +2 -1
  69. package/src/tui/slash-commands.mjs +11 -7
  70. package/src/tui/startup.mjs +4 -3
  71. package/src/tui/wizard.mjs +3 -0
  72. package/src/tools/checkpoint.md +0 -15
  73. package/src/tools/git_diff.md +0 -11
  74. package/src/tools/git_log.md +0 -10
  75. package/src/tools/git_status.md +0 -8
  76. package/src/tools/linter.md +0 -13
  77. package/src/tools/syntax_check.md +0 -10
package/src/session.mjs CHANGED
@@ -1,20 +1,33 @@
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 up to 5 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.1~5 (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
17
  const MAX_SLOTS = 5
15
18
  const CWD_HASH_LEN = 12
16
19
 
17
- /** Derive session file path from cwd hash */
20
+ let currentSessionId = null
21
+
22
+ /** Generate unique session ID for this process */
23
+ export function getSessionId() {
24
+ if (!currentSessionId) {
25
+ currentSessionId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
26
+ }
27
+ return currentSessionId
28
+ }
29
+
30
+ /** Derive base session path from cwd hash (legacy, kept for migration and tests) */
18
31
  export function sessionPath(cwd) {
19
32
  const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
20
33
  return join(configDir, "sessions", `${hash}.json`)
@@ -23,9 +36,12 @@ export function sessionPath(cwd) {
23
36
  function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
24
37
  function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
25
38
 
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. */
39
+ /** Path to the active slot's file */
40
+ export function activePath(cwd) {
41
+ return slotPath(cwd, activeSlot(cwd))
42
+ }
43
+
44
+ /** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash. */
29
45
  function writeSessionFile(p, data) {
30
46
  mkdirSync(dirname(p), { recursive: true })
31
47
  const tmp = `${p}.tmp`
@@ -33,15 +49,11 @@ function writeSessionFile(p, data) {
33
49
  try {
34
50
  renameSync(tmp, p)
35
51
  } catch {
36
- // Windows: rename may fail due to antivirus lock / network drive contention — delete target and retry
37
52
  try { unlinkSync(p) } catch {}
38
53
  try {
39
54
  renameSync(tmp, p)
40
- // rename succeeded: clean up temp file
41
55
  try { unlinkSync(tmp) } catch {}
42
56
  } 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
57
  writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
46
58
  }
47
59
  }
@@ -49,86 +61,188 @@ function writeSessionFile(p, data) {
49
61
 
50
62
  // ========== slot management ==========
51
63
 
64
+ /** Normalize slot metadata: old format is a raw timestamp number; new format is { ts, ... } object */
65
+ function slotMetaTs(v) { return typeof v === "number" ? v : (v?.ts ?? 0) }
66
+
67
+ function slotCmp(a, b) { return slotMetaTs(a[1]) - slotMetaTs(b[1]) }
68
+
69
+ /** Detect a genuine user message (excludes system-reminder injected messages) */
70
+ function isRealUserMsg(m) {
71
+ return m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
72
+ }
73
+
74
+ /** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
75
+ function extractSlotMeta(history, activeProvider, updatedAt) {
76
+ const userMsgs = history.filter(isRealUserMsg)
77
+ const first = userMsgs[0]?.content ?? ""
78
+ return {
79
+ messageCount: history.length,
80
+ turnCount: userMsgs.length,
81
+ firstMessage: first.slice(0, 80),
82
+ activeProvider: activeProvider ?? "",
83
+ updatedAt: updatedAt ?? Date.now(),
84
+ }
85
+ }
86
+
87
+ /** Extract preview summary from session data for manifest storage (with current timestamp) */
88
+ function slotDigest(data) {
89
+ const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt)
90
+ return { ts: Date.now(), ...meta }
91
+ }
92
+
52
93
  function loadManifest(cwd) {
53
94
  try {
54
95
  const p = manifestPath(cwd)
55
- if (!existsSync(p)) return { slots: {} }
56
- return JSON.parse(readFileSync(p, "utf8"))
57
- } catch { return { slots: {} } }
96
+ if (!existsSync(p)) return { slots: {}, sessionId: null }
97
+ const m = JSON.parse(readFileSync(p, "utf8"))
98
+ if (!m.sessionId) m.sessionId = null
99
+ return m
100
+ } catch { return { slots: {}, sessionId: null } }
58
101
  }
59
102
 
60
103
  function saveManifest(cwd, m) {
104
+ m.sessionId = getSessionId()
61
105
  writeSessionFile(manifestPath(cwd), m)
62
106
  }
63
107
 
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)
108
+ /**
109
+ * Ensure an active slot exists in the manifest, migrating legacy data if needed.
110
+ * Called by activeSlot() — idempotent, safe to call repeatedly.
111
+ */
112
+ function ensureActive(cwd, m) {
113
+ const mySessionId = getSessionId()
114
+
115
+ // Initialize slotSessions if not present
116
+ if (!m.slotSessions) m.slotSessions = {}
117
+
118
+ // Check if we already own the active slot
119
+ if (m.active && m.slotSessions[m.active] === mySessionId) {
120
+ return
121
+ }
122
+
123
+ // Try to find an available slot:
124
+ // 1. Empty slots (not in slotSessions)
125
+ // 2. Slots owned by dead processes (check if PID is still running)
126
+ // 3. Oldest slot if all are busy
127
+
128
+ const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
129
+
130
+ // Find first empty or dead slot
131
+ for (const slot of allSlots) {
132
+ const ownerSessionId = m.slotSessions[slot]
133
+ if (!ownerSessionId) {
134
+ // Empty slot - claim it
135
+ m.active = slot
136
+ m.slotSessions[slot] = mySessionId
137
+ saveManifest(cwd, m)
138
+ return
139
+ }
140
+ if (ownerSessionId !== mySessionId) {
141
+ // Check if owner process is still alive
142
+ const ownerPid = parseInt(ownerSessionId.split('-')[0])
143
+ if (!isProcessAlive(ownerPid)) {
144
+ // Dead process - reclaim slot
145
+ m.active = slot
146
+ m.slotSessions[slot] = mySessionId
147
+ saveManifest(cwd, m)
148
+ return
149
+ }
150
+ }
151
+ }
152
+
153
+ // All slots busy - create new slot if under limit, otherwise use oldest
154
+ if (allSlots.length < MAX_SLOTS) {
155
+ const newSlot = allSlots.length > 0 ? Math.max(...allSlots) + 1 : 1
156
+ m.active = newSlot
157
+ m.slotSessions[newSlot] = mySessionId
158
+ saveManifest(cwd, m)
159
+ return
160
+ }
161
+
162
+ // All slots busy and at limit - use oldest slot (smallest number)
163
+ m.active = allSlots[0]
164
+ m.slotSessions[m.active] = mySessionId
165
+ saveManifest(cwd, m)
166
+ }
69
167
 
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])
168
+ /**
169
+ * Check if a process with given PID is still alive.
170
+ * Returns false if process doesn't exist or we can't determine.
171
+ */
172
+ function isProcessAlive(pid) {
173
+ if (!pid || isNaN(pid)) return false
174
+ try {
175
+ // On Windows: tasklist /FI "PID eq <pid>" /NH
176
+ // On Unix: kill(pid, 0) or check /proc/<pid>
177
+ if (process.platform === 'win32') {
178
+ const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', stdio: 'pipe' })
179
+ return output.includes(String(pid))
180
+ } else {
181
+ // Unix: try to send signal 0 (doesn't kill, just checks)
182
+ process.kill(pid, 0)
183
+ return true
184
+ }
185
+ } catch {
186
+ return false
79
187
  }
188
+ }
189
+
190
+ /** Return the active slot number, migrating legacy data if necessary */
191
+ export function activeSlot(cwd) {
192
+ const m = loadManifest(cwd)
193
+ if (!m.active) ensureActive(cwd, m)
194
+ return m.active
195
+ }
80
196
 
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
197
+ /** Lazy-load slot metadata from slot file (for old-format manifest entries that lack metadata) */
198
+ function loadSlotMeta(cwd, slot, v) {
199
+ if (typeof v === "object" && v !== null && "ts" in v) return v
200
+ const ts = typeof v === "number" ? v : 0
84
201
  try {
85
- data = JSON.parse(readFileSync(src, "utf8"))
202
+ const p = slotPath(cwd, slot)
203
+ if (!existsSync(p)) return { ts }
204
+ const data = JSON.parse(readFileSync(p, "utf8"))
205
+ const history = data.history ?? []
206
+ const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts)
207
+ return { ts, ...meta }
86
208
  } catch {
87
- // Session file corrupted, abandon archive; next save will overwrite
88
- return
209
+ return { ts }
89
210
  }
90
- writeSessionFile(dst, data)
91
- m.slots[slot] = Date.now()
92
- delete m.slots._currentName
93
- saveManifest(cwd, m)
94
- return slot
95
211
  }
96
212
 
97
- /** List all archive slots, newest first */
213
+ /** List all slots, newest first. Includes isActive flag. */
98
214
  export function listSlots(cwd) {
99
215
  const m = loadManifest(cwd)
216
+ const active = m.active ?? activeSlot(cwd)
100
217
  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)
218
+ .filter(([n]) => /^\d+$/.test(n))
219
+ .map(([n, v]) => {
220
+ const meta = loadSlotMeta(cwd, Number(n), v)
221
+ return {
222
+ slot: Number(n),
223
+ isActive: Number(n) === active,
224
+ timestamp: meta.ts,
225
+ date: new Date(meta.ts).toLocaleString(),
226
+ messageCount: meta.messageCount ?? 0,
227
+ turnCount: meta.turnCount ?? 0,
228
+ firstMessage: meta.firstMessage ?? "",
229
+ activeProvider: meta.activeProvider ?? "",
230
+ updatedAt: meta.updatedAt ?? meta.ts,
231
+ updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
232
+ }
233
+ })
234
+ .sort((a, b) => b.updatedAt - a.updatedAt)
103
235
  }
104
236
 
105
- /** Switch to a specific slot: archive current → copy slot file to current → return recovered data (null on failure) */
237
+ /**
238
+ * Switch active slot. No file copying — just change the pointer in the manifest.
239
+ * Returns the loaded session data (null if slot doesn't exist).
240
+ */
106
241
  export function switchToSlot(cwd, slot) {
107
242
  const m = loadManifest(cwd)
108
243
  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 })
113
-
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
118
- try {
119
- try { unlinkSync(dst) } catch { /* doesn't exist, that's fine */ }
120
- copyFileSync(src, dst)
121
- unlinkSync(src)
122
- } catch {
123
- // File operations failed (disk full / permissions / lock), abandon switch
124
- return null
125
- }
126
-
127
- // Reload manifest (archiveCurrent modified it)
128
- const m2 = loadManifest(cwd)
129
- delete m2.slots[slot]
130
- saveManifest(cwd, m2)
131
-
244
+ m.active = slot
245
+ saveManifest(cwd, m)
132
246
  return loadSession(cwd)
133
247
  }
134
248
 
@@ -149,28 +263,41 @@ function isLegacyTransient(m) {
149
263
 
150
264
  // ========== core read/write ==========
151
265
 
152
- /** Save agent state and display lines to the session file (atomic write) */
266
+ /** Save agent state and display lines to the active slot file (atomic write) */
153
267
  export function saveSession(agent, display) {
154
268
  const history = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
155
269
  const data = {
156
270
  version: 2,
157
271
  cwd: agent.cwd,
158
272
  activeProvider: agent.activeProvider ?? agent.provider?.name,
273
+ activeModel: agent.activeModel ?? null,
159
274
  updatedAt: Date.now(),
160
275
  history,
161
276
  display: display ?? [],
162
277
  tasks: agent.tasks ?? [],
163
278
  planMode: agent.planMode ?? false,
164
279
  autoApprove: agent.autoApprove ?? false,
280
+ engineering: agent.config?.agent?.engineering ?? false,
281
+ engDesignToken: agent._engDesignToken ?? null,
165
282
  goal: agent.goal ?? null,
166
283
  advisor: agent.config?.advisor ?? null,
167
284
  pendingReminders: agent._pendingReminders ?? [],
168
285
  sessionStart: agent._sessionStart ?? null,
169
286
  }
170
- writeSessionFile(sessionPath(agent.cwd), data)
287
+ const slot = activeSlot(agent.cwd)
288
+ writeSessionFile(slotPath(agent.cwd, slot), data)
289
+ // Update slot metadata in manifest
290
+ try {
291
+ const m = loadManifest(agent.cwd)
292
+ m.slots[slot] = slotDigest(data)
293
+ saveManifest(agent.cwd, m)
294
+ } catch (e) {
295
+ // Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
296
+ console.error(`[session] manifest metadata update failed for slot ${slot}: ${e.message}`)
297
+ }
171
298
  }
172
299
 
173
- /** Load session data from disk; returns null if missing, corrupted, or version mismatch */
300
+ /** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
174
301
  export function loadSession(cwd) {
175
302
  const tryLoad = (p) => {
176
303
  try {
@@ -189,23 +316,36 @@ export function loadSession(cwd) {
189
316
  return { _error: e }
190
317
  }
191
318
  }
192
- const p = sessionPath(cwd)
319
+
320
+ // Try the active slot first (post-migration, legacy file may be stale)
321
+ const slot = activeSlot(cwd)
322
+ const p = slotPath(cwd, slot)
193
323
  let result = tryLoad(p)
324
+ if (result && !result._error) return result
194
325
  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...`)
326
+ console.error(`[session] failed to load slot ${slot}: ${result._error.message}. Trying .tmp fallback...`)
197
327
  const tmpResult = tryLoad(`${p}.tmp`)
198
328
  if (tmpResult && !tmpResult._error) {
199
329
  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
330
+ tmpResult._recovered = true
331
+ return tmpResult
206
332
  }
333
+ console.error(`[session] .tmp fallback also failed — session lost.`)
334
+ try { renameSync(p, `${p}.corrupted`) } catch {}
335
+ }
336
+
337
+ // Fallback: try the legacy current file (pre-migration, or migration failed to clean up)
338
+ const legacy = sessionPath(cwd)
339
+ result = tryLoad(legacy)
340
+ if (result && !result._error) {
341
+ return result
207
342
  }
208
- return result && !result._error ? result : null
343
+ if (result?._error) {
344
+ console.error(`[session] failed to load legacy ${legacy}: ${result._error.message}`)
345
+ try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
346
+ }
347
+
348
+ return null
209
349
  }
210
350
 
211
351
  /** Apply loaded session data onto an agent object; returns true if provider was switched */
@@ -217,6 +357,7 @@ export function applySession(agent, data) {
217
357
  agent.goal = data.goal ?? null
218
358
  agent._pendingReminders = data.pendingReminders ?? []
219
359
  agent._sessionStart = data.sessionStart ?? null
360
+ agent._engDesignToken = data.engDesignToken ?? null
220
361
  if (data.advisor) {
221
362
  agent.config.advisor = { ...data.advisor }
222
363
  }
@@ -229,19 +370,59 @@ export function applySession(agent, data) {
229
370
  if (p) {
230
371
  agent.provider = { ...p }
231
372
  agent.activeProvider = p.name
373
+ agent.activeModel = data.activeModel ?? null
374
+ if (agent.activeModel) agent.provider.model = agent.activeModel
232
375
  return true
233
376
  }
377
+ } else if (data.activeModel != null) {
378
+ // Same provider, different model
379
+ agent.activeModel = data.activeModel
380
+ if (agent.activeModel && agent.provider) agent.provider.model = agent.activeModel
381
+ } else if (data.activeProvider && data.activeProvider === agent.activeProvider) {
382
+ // Same provider, session has no activeModel → clear stale override
383
+ agent.activeModel = null
384
+ if (agent.provider) {
385
+ const p = agent.providers?.find((pr) => pr.name === agent.activeProvider)
386
+ if (p) agent.provider.model = p.model
387
+ }
234
388
  }
235
389
  return false
236
390
  }
237
391
 
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
392
+ /**
393
+ * Create a new session slot: allocate a free slot number,
394
+ * write an empty session, and mark it as the active slot.
395
+ * Evicts the oldest slot if MAX_SLOTS is reached.
396
+ */
397
+ export function newSession(cwd) {
398
+ const m = loadManifest(cwd)
399
+ // Ensure active is set (migrate if needed)
400
+ if (!m.active) ensureActive(cwd, m)
401
+
402
+ let slot
403
+ const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
404
+ if (entries.length < MAX_SLOTS) {
405
+ slot = 1
406
+ while (m.slots[slot]) slot++
407
+ } else {
408
+ // Full — evict oldest (but never evict the currently active slot)
409
+ const candidates = entries.filter(([n]) => Number(n) !== m.active)
410
+ if (candidates.length === 0) {
411
+ // Should not happen with MAX_SLOTS >= 2 — manifest corruption or all slots are active
412
+ console.error(`[session] newSession: all ${entries.length} slots are active, cannot evict. Overwriting oldest non-active slot skipped; reusing slot 1.`)
413
+ slot = 1
414
+ } else {
415
+ slot = Number(candidates.sort(slotCmp)[0][0])
416
+ }
417
+ // Delete the evicted slot file
418
+ try { unlinkSync(slotPath(cwd, slot)) } catch {}
246
419
  }
420
+
421
+ // Write empty session
422
+ const data = { version: 2, cwd, updatedAt: Date.now(), history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
423
+ writeSessionFile(slotPath(cwd, slot), data)
424
+ m.slots[slot] = slotDigest(data)
425
+ m.active = slot
426
+ saveManifest(cwd, m)
427
+ return slot
247
428
  }
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
@@ -24,7 +24,7 @@
24
24
  import { Script, createContext } from "node:vm"
25
25
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
26
26
  import { join, dirname, relative, resolve } from "node:path"
27
- import { globToRegex, normalizeEOL, isPrivateHost } from "./shared.mjs"
27
+ import { DESC, globToRegex, normalizeEOL, isPrivateHost } from "./shared.mjs"
28
28
 
29
29
  const MAX_OUTPUT = 50_000
30
30
  const MAX_SCRIPT = 50_000
@@ -53,9 +53,7 @@ async function sandboxFetch(url) {
53
53
 
54
54
  export const codeModeTool = {
55
55
  name: "execute",
56
- description:
57
- "Execute sandboxed JavaScript code. Use this to compose multiple file operations into one call — " +
58
- "read, write, glob, grep, and log results. No network or system access. Max 30s timeout, 50KB output.",
56
+ description: DESC("execute"),
59
57
  parameters: {
60
58
  type: "object",
61
59
  properties: {
@@ -96,6 +94,11 @@ export const codeModeTool = {
96
94
  return abs
97
95
  }
98
96
 
97
+ // Block dynamic imports: check for import() syntax before execution
98
+ if (/\bimport\s*\(/.test(code)) {
99
+ return "Error: dynamic import() is not allowed in CodeMode sandbox. Use the provided readFile/writeFile/glob/grep/fetch functions instead."
100
+ }
101
+
99
102
  const sandbox = createContext({
100
103
  readFile: (p) => {
101
104
  const abs = safePath(p)
@@ -149,6 +152,9 @@ export const codeModeTool = {
149
152
  }
150
153
  },
151
154
  fetch: sandboxFetch,
155
+ // Block process and require access
156
+ require: () => { throw new Error("require() is not available in CodeMode sandbox") },
157
+ process: undefined,
152
158
  })
153
159
 
154
160
  try {
@@ -2,6 +2,7 @@ Delete a file. Use when the agent created a temporary or junk file that should b
2
2
 
3
3
  Parameters:
4
4
  - path (required): File path, relative to cwd or absolute
5
+ - force: Allow deleting git-tracked files (default false)
5
6
 
6
7
  Notes:
7
8
  - Untracked or non-git files are deleted immediately
package/src/tools/edit.md CHANGED
@@ -15,6 +15,6 @@ Parameters:
15
15
  - replace_all: Replace all occurrences instead of just one (default false)
16
16
 
17
17
  Notes:
18
- - Prefer this over write for targeted edits — it's safer and keeps diffs small
18
+ - Prefer this over write for targeted edits — it's safer and keeps changes targeted
19
19
  - If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
20
20
  - Never fabricate the old_string — copy it verbatim from the actual file using read first
@@ -0,0 +1,5 @@
1
+ Execute sandboxed JavaScript code. Use this to compose multiple file operations into one call — read, write, glob, grep, and log results. No network or system access. Max 30s timeout, 50KB output.
2
+
3
+ Parameters:
4
+ - code (required): JavaScript code to execute in the sandbox. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args).
5
+ - timeoutMs: Timeout in milliseconds (default 30000, max 60000)