thincoder 0.12.2 → 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.
- package/README.md +18 -5
- package/package.json +1 -1
- 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 +119 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +93 -5
- 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 +110 -150
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +22 -4
- 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/session.mjs +270 -89
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/codemode.mjs +10 -4
- 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 +169 -66
- package/src/tui/cmd-config.mjs +12 -0
- 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 +7 -6
- package/src/tui/key-handler.mjs +132 -4
- package/src/tui/layout.mjs +5 -5
- package/src/tui/pickers.mjs +184 -44
- package/src/tui/render-conversation.mjs +49 -11
- package/src/tui/render-frame.mjs +38 -12
- package/src/tui/render-loop.mjs +2 -1
- 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,20 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* session.mjs — session persistence
|
|
3
|
-
* Each project (keyed by cwd hash) keeps up to 5 session slots
|
|
4
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
102
|
-
.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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/codemode.mjs
CHANGED
|
@@ -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 {
|
package/src/tools/delete.md
CHANGED
|
@@ -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
|
|
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)
|