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.
- package/README.md +29 -6
- package/package.json +3 -3
- package/src/advisor/history.mjs +112 -0
- package/src/advisor/messages.mjs +182 -0
- package/src/advisor/repos.mjs +133 -0
- package/src/advisor/run.mjs +346 -0
- package/src/advisor.mjs +109 -509
- package/src/agent/completion.mjs +134 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +95 -6
- package/src/agent-tools/advisor.mjs +159 -12
- package/src/agent-tools/eng.mjs +64 -0
- package/src/agent-tools/subagent.mjs +73 -3
- package/src/agent-tools/task.mjs +45 -6
- package/src/agent-tools/verify.mjs +18 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +152 -161
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +34 -4
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- package/src/prompts/advisor-design.md +43 -0
- package/src/prompts/advisor-round1.md +11 -4
- package/src/prompts/advisor-round2.md +12 -7
- package/src/prompts/advisor-round3.md +11 -6
- package/src/prompts/coder.md +9 -3
- package/src/prompts/discipline.md +12 -96
- package/src/prompts/eng-coder.md +34 -0
- package/src/prompts/engineering-sub.md +12 -0
- package/src/prompts/engineering.md +96 -0
- package/src/prompts/main.md +1 -1
- package/src/prompts/methodology-template.md +39 -0
- package/src/prompts/plan.md +2 -2
- package/src/prompts/system.md +43 -61
- package/src/provider/core.mjs +58 -2
- package/src/session.mjs +291 -94
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +23 -11
- package/src/tools/delete.md +1 -0
- package/src/tools/edit.md +1 -1
- package/src/tools/execute.md +5 -0
- package/src/tools/file.mjs +4 -0
- package/src/tools/git.md +15 -0
- package/src/tools/git.mjs +1 -6
- package/src/tools/lint.md +8 -0
- package/src/tools/linter.mjs +1 -5
- package/src/tools/lsp.md +7 -0
- package/src/tools/lsp.mjs +8 -9
- package/src/tools/patch.mjs +1 -29
- package/src/tools/read_image.md +5 -1
- package/src/tools/system.mjs +1 -1
- package/src/tools/web.mjs +3 -3
- package/src/tui/agent-turn.mjs +184 -66
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +14 -26
- package/src/tui/cmd-eng.mjs +44 -0
- package/src/tui/cmd-exit.mjs +1 -1
- package/src/tui/cmd-fold.mjs +3 -4
- package/src/tui/cmd-model.mjs +11 -6
- package/src/tui/cmd-new.mjs +5 -5
- package/src/tui/cmd-session.mjs +21 -11
- package/src/tui/cmd-think.mjs +1 -0
- package/src/tui/index.mjs +20 -9
- package/src/tui/key-handler.mjs +177 -9
- package/src/tui/layout.mjs +5 -5
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +190 -45
- package/src/tui/render-conversation.mjs +54 -13
- package/src/tui/render-frame.mjs +39 -12
- package/src/tui/render-loop.mjs +2 -1
- package/src/tui/render.mjs +13 -7
- package/src/tui/slash-commands.mjs +11 -7
- package/src/tui/startup.mjs +4 -3
- package/src/tui/wizard.mjs +3 -0
- package/src/tools/checkpoint.md +0 -15
- package/src/tools/git_diff.md +0 -11
- package/src/tools/git_log.md +0 -10
- package/src/tools/git_status.md +0 -8
- package/src/tools/linter.md +0 -13
- 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
|
|
4
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
15
|
-
const CWD_HASH_LEN = 12
|
|
17
|
+
let currentSessionId = null
|
|
16
18
|
|
|
17
|
-
/**
|
|
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 =
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
/**
|
|
106
|
-
export function
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
return null
|
|
225
|
+
return { ts }
|
|
125
226
|
}
|
|
227
|
+
}
|
|
126
228
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
283
|
+
/** Save agent state and display lines to the active slot file (atomic write) */
|
|
153
284
|
export function saveSession(agent, display) {
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
} catch { /* not found, try flat */ }
|
|
129
|
+
const dirs = [
|
|
130
|
+
join(cwd, ".thincoder", "skills"),
|
|
131
|
+
join(homedir(), ".thincoder", "skills")
|
|
132
|
+
]
|
|
108
133
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
}
|
package/src/tools/apply_patch.md
CHANGED
|
@@ -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
|
|
11
|
+
- For single-file edits, edit is simpler; for full rewrites, write is simpler
|
package/src/tools/checklist.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|