thincoder 0.12.52 → 0.12.54
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/CHANGELOG.md +36 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/helpers.mjs +14 -5
- package/src/mcp/transport-http.mjs +79 -27
- package/src/mcp/transport-stdio.mjs +57 -3
- package/src/mcp/transport-ws.mjs +46 -12
- package/src/mcp.mjs +197 -58
- package/src/prompts/discipline.md +44 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +163 -36
- package/src/provider/google.mjs +41 -15
- package/src/provider/rate.mjs +5 -0
- package/src/provider/responses.mjs +498 -0
- package/src/provider/retry.mjs +125 -0
- package/src/provider/sse.mjs +58 -24
- package/src/proxy.mjs +36 -6
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- package/src/tools/bash.md +2 -2
- package/src/tools/execute.md +1 -1
- package/src/tools/execute.mjs +3 -3
- package/src/tools/fetch.md +1 -0
- package/src/tools/file.mjs +136 -11
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -6
- package/src/tools/git.mjs +68 -155
- package/src/tools/shared.mjs +5 -3
- package/src/tools/system.mjs +19 -1
- package/src/tools/web.mjs +44 -14
- package/src/tools/websearch.md +3 -1
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +59 -67
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +86 -8
- package/src/tui/render-conversation.mjs +260 -214
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +11 -1
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-args.mjs +4 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/session.mjs
CHANGED
|
@@ -6,283 +6,24 @@
|
|
|
6
6
|
*
|
|
7
7
|
* File layout: {hash}.json.N (slots), {hash}.json.manifest (slot metadata + active pointer).
|
|
8
8
|
* Legacy {hash}.json is migrated to a slot on first access.
|
|
9
|
+
*
|
|
10
|
+
* 2026-08-31 advisor round1 🔴:slot/清单管理拆至 session-slots.mjs(本文件曾超 500 行
|
|
11
|
+
* 硬限);本文件只保留核心读写 + re-export 全部 slot 导出(既有 import 路径不变)。
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
let currentSessionId = null
|
|
19
|
-
|
|
20
|
-
/** Generate unique session ID for this process */
|
|
21
|
-
export function getSessionId() {
|
|
22
|
-
if (!currentSessionId) {
|
|
23
|
-
currentSessionId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
24
|
-
}
|
|
25
|
-
return currentSessionId
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Normalize cwd for hashing: uppercase Windows drive letter so both ends
|
|
29
|
-
* (CLI's process.cwd() vs VS Code's uri.fsPath, which lowercases it) agree. */
|
|
30
|
-
export function normalizeCwd(cwd) {
|
|
31
|
-
return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Full sha1 hex (40 chars), not truncated. Shared contract with the VS Code extension. */
|
|
35
|
-
function cwdHash(cwd) {
|
|
36
|
-
return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
|
|
40
|
-
export function sessionPath(cwd) {
|
|
41
|
-
const hash = cwdHash(cwd)
|
|
42
|
-
migrateHashLength(cwd, hash)
|
|
43
|
-
return join(configDir, "sessions", `${hash}.json`)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
|
|
47
|
-
export { slotPath }
|
|
48
|
-
function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
|
|
49
|
-
|
|
50
|
-
/** Path to the active slot's file */
|
|
51
|
-
export function activePath(cwd) {
|
|
52
|
-
return slotPath(cwd, activeSlot(cwd))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash. */
|
|
56
|
-
function writeSessionFile(p, data) {
|
|
57
|
-
mkdirSync(dirname(p), { recursive: true })
|
|
58
|
-
const tmp = `${p}.tmp`
|
|
59
|
-
writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
60
|
-
try {
|
|
61
|
-
renameSync(tmp, p)
|
|
62
|
-
} catch {
|
|
63
|
-
try { unlinkSync(p) } catch {}
|
|
64
|
-
try {
|
|
65
|
-
renameSync(tmp, p)
|
|
66
|
-
try { unlinkSync(tmp) } catch {}
|
|
67
|
-
} catch {
|
|
68
|
-
writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ========== slot management ==========
|
|
74
|
-
|
|
75
|
-
/** Normalize slot metadata: old format is a raw timestamp number; new format is { ts, ... } object */
|
|
76
|
-
function slotMetaTs(v) { return typeof v === "number" ? v : (v?.ts ?? 0) }
|
|
77
|
-
|
|
78
|
-
function slotCmp(a, b) { return slotMetaTs(a[1]) - slotMetaTs(b[1]) }
|
|
79
|
-
|
|
80
|
-
/** Detect a genuine user message (excludes system-reminder injected messages) */
|
|
81
|
-
function isRealUserMsg(m) {
|
|
82
|
-
return m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
|
|
86
|
-
function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
|
|
87
|
-
const userMsgs = history.filter(isRealUserMsg)
|
|
88
|
-
const first = userMsgs[0]?.content ?? ""
|
|
89
|
-
return {
|
|
90
|
-
messageCount: history.length,
|
|
91
|
-
turnCount: userMsgs.length,
|
|
92
|
-
firstMessage: first.slice(0, 80),
|
|
93
|
-
activeProvider: activeProvider ?? "",
|
|
94
|
-
updatedAt: updatedAt ?? Date.now(),
|
|
95
|
-
title,
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Extract preview summary from session data for manifest storage (with current timestamp) */
|
|
100
|
-
function slotDigest(data) {
|
|
101
|
-
const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
|
|
102
|
-
return { ts: Date.now(), ...meta }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function loadManifest(cwd) {
|
|
106
|
-
try {
|
|
107
|
-
const p = manifestPath(cwd)
|
|
108
|
-
if (!existsSync(p)) return { slots: {}, sessionId: null }
|
|
109
|
-
const m = JSON.parse(readFileSync(p, "utf8"))
|
|
110
|
-
if (!m.sessionId) m.sessionId = null
|
|
111
|
-
return m
|
|
112
|
-
} catch { return { slots: {}, sessionId: null } }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function saveManifest(cwd, m) {
|
|
116
|
-
m.sessionId = getSessionId()
|
|
117
|
-
writeSessionFile(manifestPath(cwd), m)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Claim a slot for this process and set it as active. Idempotent.
|
|
122
|
-
* Preference order:
|
|
123
|
-
* 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
|
|
124
|
-
* 2. Any slot that is unowned or owned by a dead process (reclaim).
|
|
125
|
-
* 3. A brand-new slot when all are owned by live processes.
|
|
126
|
-
* The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
|
|
127
|
-
* see which slots are taken and avoid them.
|
|
128
|
-
*/
|
|
129
|
-
function ensureActive(cwd, m) {
|
|
130
|
-
const mySessionId = getSessionId()
|
|
131
|
-
if (!m.slotSessions) m.slotSessions = {}
|
|
132
|
-
|
|
133
|
-
// Already own the active slot — nothing to do.
|
|
134
|
-
if (m.active && m.slotSessions[m.active] === mySessionId) return
|
|
135
|
-
|
|
136
|
-
const isFree = (slot) => {
|
|
137
|
-
const owner = m.slotSessions[slot]
|
|
138
|
-
if (!owner || owner === mySessionId) return true
|
|
139
|
-
return !isProcessAlive(parseInt(owner.split('-')[0]))
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
|
|
143
|
-
if (m.active && m.slots[m.active] && isFree(m.active)) {
|
|
144
|
-
m.slotSessions[m.active] = mySessionId
|
|
145
|
-
saveManifest(cwd, m)
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 2. Otherwise claim the first slot that is free.
|
|
150
|
-
const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
|
|
151
|
-
for (const slot of allSlots) {
|
|
152
|
-
if (isFree(slot)) {
|
|
153
|
-
m.active = slot
|
|
154
|
-
m.slotSessions[slot] = mySessionId
|
|
155
|
-
saveManifest(cwd, m)
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// 3. All slots owned by live processes — allocate a new one (no limit).
|
|
161
|
-
const newSlot = allSlots.length > 0 ? Math.max(...allSlots) + 1 : 1
|
|
162
|
-
m.active = newSlot
|
|
163
|
-
m.slotSessions[newSlot] = mySessionId
|
|
164
|
-
saveManifest(cwd, m)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Check if a process with given PID is still alive.
|
|
169
|
-
* Returns false if process doesn't exist or we can't determine.
|
|
170
|
-
*/
|
|
171
|
-
function isProcessAlive(pid) {
|
|
172
|
-
if (!pid || isNaN(pid)) return false
|
|
173
|
-
try {
|
|
174
|
-
// On Windows: tasklist /FI "PID eq <pid>" /NH
|
|
175
|
-
// On Unix: kill(pid, 0) or check /proc/<pid>
|
|
176
|
-
if (process.platform === 'win32') {
|
|
177
|
-
const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', stdio: 'pipe' })
|
|
178
|
-
return output.includes(String(pid))
|
|
179
|
-
} else {
|
|
180
|
-
// Unix: try to send signal 0 (doesn't kill, just checks)
|
|
181
|
-
process.kill(pid, 0)
|
|
182
|
-
return true
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
return false
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Return the active slot number for this process, claiming one if necessary */
|
|
190
|
-
export function activeSlot(cwd) {
|
|
191
|
-
const m = loadManifest(cwd)
|
|
192
|
-
ensureActive(cwd, m)
|
|
193
|
-
return m.active
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Lazy-load slot metadata from slot file (for old-format manifest entries that lack metadata) */
|
|
197
|
-
function loadSlotMeta(cwd, slot, v) {
|
|
198
|
-
if (typeof v === "object" && v !== null && "ts" in v) return v
|
|
199
|
-
const ts = typeof v === "number" ? v : 0
|
|
200
|
-
try {
|
|
201
|
-
const p = slotPath(cwd, slot)
|
|
202
|
-
if (!existsSync(p)) return { ts }
|
|
203
|
-
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
204
|
-
const history = data.history ?? []
|
|
205
|
-
const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
|
|
206
|
-
return { ts, ...meta }
|
|
207
|
-
} catch {
|
|
208
|
-
return { ts }
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** List all slots, newest first. Includes isActive flag. */
|
|
213
|
-
export function listSlots(cwd) {
|
|
214
|
-
const m = loadManifest(cwd)
|
|
215
|
-
const active = m.active ?? activeSlot(cwd)
|
|
216
|
-
return Object.entries(m.slots)
|
|
217
|
-
.filter(([n]) => /^\d+$/.test(n))
|
|
218
|
-
.map(([n, v]) => {
|
|
219
|
-
const meta = loadSlotMeta(cwd, Number(n), v)
|
|
220
|
-
return {
|
|
221
|
-
slot: Number(n),
|
|
222
|
-
isActive: Number(n) === active,
|
|
223
|
-
timestamp: meta.ts,
|
|
224
|
-
date: new Date(meta.ts).toLocaleString(),
|
|
225
|
-
messageCount: meta.messageCount ?? 0,
|
|
226
|
-
turnCount: meta.turnCount ?? 0,
|
|
227
|
-
firstMessage: meta.firstMessage ?? "",
|
|
228
|
-
activeProvider: meta.activeProvider ?? "",
|
|
229
|
-
updatedAt: meta.updatedAt ?? meta.ts,
|
|
230
|
-
updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
|
|
231
|
-
title: meta.title ?? "",
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
235
|
-
}
|
|
236
|
-
|
|
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
|
-
*/
|
|
241
|
-
export function switchToSlot(cwd, slot) {
|
|
242
|
-
const m = loadManifest(cwd)
|
|
243
|
-
if (!m.slots[slot]) return null
|
|
244
|
-
m.active = slot
|
|
245
|
-
saveManifest(cwd, m)
|
|
246
|
-
return loadSession(cwd)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Delete a slot: remove its file and manifest entry. Deleting the active slot
|
|
250
|
-
* resets the manifest active pointer (the next claim re-creates one). */
|
|
251
|
-
export function deleteSlot(cwd, slot) {
|
|
252
|
-
const n = Number(slot)
|
|
253
|
-
if (!Number.isInteger(n) || n < 1) return false
|
|
254
|
-
const m = loadManifest(cwd)
|
|
255
|
-
if (!m.slots[n]) return false
|
|
256
|
-
delete m.slots[n]
|
|
257
|
-
delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
|
|
258
|
-
try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
|
|
259
|
-
if (m.active === n) delete m.active
|
|
260
|
-
saveManifest(cwd, m)
|
|
261
|
-
return true
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Rename a slot: update the slot file's title + the manifest metadata (shared with VS Code). */
|
|
265
|
-
export function renameSlot(cwd, slot, title) {
|
|
266
|
-
const n = Number(slot)
|
|
267
|
-
if (!Number.isInteger(n) || n < 1) return false
|
|
268
|
-
const p = slotPath(cwd, n)
|
|
269
|
-
if (!existsSync(p)) return false
|
|
270
|
-
let data
|
|
271
|
-
try {
|
|
272
|
-
data = JSON.parse(readFileSync(p, "utf8"))
|
|
273
|
-
} catch {
|
|
274
|
-
return false
|
|
275
|
-
}
|
|
276
|
-
data.title = title
|
|
277
|
-
writeSessionFile(p, data)
|
|
278
|
-
const m = loadManifest(cwd)
|
|
279
|
-
if (m.slots[n]) {
|
|
280
|
-
m.slots[n] = slotDigest(data)
|
|
281
|
-
saveManifest(cwd, m)
|
|
282
|
-
}
|
|
283
|
-
return true
|
|
284
|
-
}
|
|
14
|
+
import { readFileSync, renameSync, existsSync, statSync } from "node:fs"
|
|
15
|
+
import { basename } from "node:path"
|
|
16
|
+
import {
|
|
17
|
+
slotPath, writeSessionFile, loadManifest, saveManifest, slotDigest,
|
|
18
|
+
activeSlot, sessionPath, getSessionId, isProcessAlive,
|
|
19
|
+
} from "./session-slots.mjs"
|
|
285
20
|
|
|
21
|
+
// re-export slot 管理(保持既有 import session.mjs 的调用点不变)
|
|
22
|
+
export {
|
|
23
|
+
getSessionId, normalizeCwd, sessionPath, slotPath, manifestPath, activePath,
|
|
24
|
+
writeSessionFile, slotDigest, loadManifest, saveManifest, activeSlot, listSlots,
|
|
25
|
+
deleteSlot, renameSlot, isProcessAlive,
|
|
26
|
+
} from "./session-slots.mjs"
|
|
286
27
|
|
|
287
28
|
// ========== legacy transient prefix cleanup ==========
|
|
288
29
|
|
|
@@ -343,7 +84,10 @@ function slimForDisplay(m) {
|
|
|
343
84
|
/** Save agent state to the active slot file (atomic write). `display` (the old
|
|
344
85
|
* WYSIWYG render snapshot) is DEPRECATED — it drifted out of sync with history
|
|
345
86
|
* whenever VS Code wrote the slot, and the TUI resumed from a stale snapshot.
|
|
346
|
-
* Restore now always rebuilds from history (lazy, see startup.mjs).
|
|
87
|
+
* Restore now always rebuilds from history (lazy, see startup.mjs).
|
|
88
|
+
* 2026-08-31 会诊 F1:slot 粘性——首次认领后缓存 agent._slot,永不重跑 ensureActive
|
|
89
|
+
* (原实现每次保存重推,manifest active 被并发方翻动时会话静默迁移)。
|
|
90
|
+
* 返回轮转的 .bak 路径或 null。 */
|
|
347
91
|
export function saveSession(agent) {
|
|
348
92
|
// _fullHistory is written at the source via pushReal — no flush needed here.
|
|
349
93
|
// history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
|
|
@@ -377,8 +121,51 @@ export function saveSession(agent) {
|
|
|
377
121
|
pendingReminders: agent._pendingReminders ?? [],
|
|
378
122
|
sessionStart: agent._sessionStart ?? null,
|
|
379
123
|
}
|
|
380
|
-
const slot = activeSlot(agent.cwd)
|
|
381
|
-
|
|
124
|
+
const slot = agent._slot ??= activeSlot(agent.cwd)
|
|
125
|
+
const p = slotPath(agent.cwd, slot)
|
|
126
|
+
// 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
|
|
127
|
+
// 进程/会话的现场)→ 先轮转 .bak 保留再写(11311 条历史被新进程覆盖的实锤场景)。
|
|
128
|
+
// 检查按 mtime 缓存(_slotMtime):文件自上次检查/自写未变就跳过全量解析——
|
|
129
|
+
// 每次保存都 readFileSync+JSON.parse 多 MB 会话 → O(n²) 退化。
|
|
130
|
+
let rotated = null
|
|
131
|
+
try {
|
|
132
|
+
if (existsSync(p)) {
|
|
133
|
+
const st = statSync(p)
|
|
134
|
+
let disk = null
|
|
135
|
+
if (st.mtimeMs !== (agent._slotMtime ?? -1)) {
|
|
136
|
+
disk = JSON.parse(readFileSync(p, "utf8"))
|
|
137
|
+
// 2026-08-31 会诊 deepseek 🟡:version>2 的新版文件无论 sessionStart 一律轮转
|
|
138
|
+
// (loadSlotFile 对 v3 返回 null 不动文件——若其 sessionStart 为 null,旧版首次
|
|
139
|
+
// 保存会静默覆盖;轮转 .bak 保证新版文件保留)。
|
|
140
|
+
// 2026-09-01 advisor 🔵:磁盘文件 cwd 不匹配(异项目文件误落本路径)同样轮转——
|
|
141
|
+
// 与 loadSlotFile/legacy 读的"别人的文件不动"原则对齐(否则 sessionStart null +
|
|
142
|
+
// version≤2 的异项目文件被静默覆盖且无 .bak)。
|
|
143
|
+
const diskIsNewer = typeof disk?.version === "number" && disk.version > 2
|
|
144
|
+
const diskStart = disk?.sessionStart ?? null
|
|
145
|
+
const myStart = agent._sessionStart ?? null
|
|
146
|
+
const diskForeign = typeof disk?.cwd === "string" && disk.cwd.toLowerCase() !== agent.cwd.toLowerCase()
|
|
147
|
+
if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
|
|
148
|
+
const bak = `${p}.bak-${Date.now()}`
|
|
149
|
+
renameSync(p, bak)
|
|
150
|
+
rotated = bak
|
|
151
|
+
console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${basename(bak)}`)
|
|
152
|
+
}
|
|
153
|
+
agent._slotMtime = st.mtimeMs
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// 文件存在但不可读(损坏/半写):改名 .corrupted 保留现场(2026-09-01 advisor 🔵——
|
|
158
|
+
// 与自身 loadSlotFile 的 .corrupted 约定 + VS Code saveSessionToSlot 对齐;.bak 保留
|
|
159
|
+
// 给 F2 轮转路径,损坏现场不再混入轮转后缀,恢复/清理工具按后缀分类不误判)
|
|
160
|
+
if (existsSync(p)) {
|
|
161
|
+
try {
|
|
162
|
+
renameSync(p, `${p}.corrupted`)
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
writeSessionFile(p, data)
|
|
167
|
+
// 记录我们刚写的 mtime——下次保存跳过重复解析
|
|
168
|
+
try { agent._slotMtime = statSync(p).mtimeMs } catch {}
|
|
382
169
|
// Update slot metadata in manifest
|
|
383
170
|
try {
|
|
384
171
|
const m = loadManifest(agent.cwd)
|
|
@@ -388,28 +175,40 @@ export function saveSession(agent) {
|
|
|
388
175
|
// Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
|
|
389
176
|
console.error(`[session] manifest metadata update failed for slot ${slot}: ${e.message}`)
|
|
390
177
|
}
|
|
178
|
+
return rotated
|
|
391
179
|
}
|
|
392
180
|
|
|
393
|
-
/** Load
|
|
394
|
-
|
|
395
|
-
|
|
181
|
+
/** Load slot file with shared validation — 2026-08-31 会诊 deepseek 🟡 抽取:
|
|
182
|
+
* loadSession(active 槽)/switchToSlot/ACP session/load 共享。无认领副作用。
|
|
183
|
+
* version 1/2 + history 数组 + cwd 匹配(2026-09-01 会诊 kimi 🔵:cwd 先行——"别人的
|
|
184
|
+
* 文件不动"优先于结构校验,异 cwd + 坏 version 不得改名);结构不符改名 .unreadable、
|
|
185
|
+
* 解析失败 .tmp 回退成功后提升为正主(损坏主文件改名 .corrupted 保留)、主文件缺失
|
|
186
|
+
* 时恢复孤儿 .tmp(rename 前崩溃现场)。 */
|
|
187
|
+
export function loadSlotFile(cwd, slot) {
|
|
188
|
+
const p = slotPath(cwd, slot)
|
|
189
|
+
const tryLoad = (path) => {
|
|
396
190
|
try {
|
|
397
|
-
if (!existsSync(
|
|
398
|
-
const data = JSON.parse(readFileSync(
|
|
399
|
-
if (data
|
|
400
|
-
if (
|
|
401
|
-
|
|
191
|
+
if (!existsSync(path)) return null
|
|
192
|
+
const data = JSON.parse(readFileSync(path, "utf8"))
|
|
193
|
+
if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null // 别人的文件,不动
|
|
194
|
+
if (data?.version !== 1 && data?.version !== 2) {
|
|
195
|
+
if (typeof data?.version === "number" && data.version > 2) return null // 新版 CLI 的文件,不动(F2 轮转兜底)
|
|
196
|
+
try { renameSync(path, `${path}.unreadable`) } catch {}
|
|
197
|
+
console.error(`[session] unsupported version ${data?.version} at ${path} — preserved as ${basename(path)}.unreadable`)
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
if (!Array.isArray(data.history)) {
|
|
201
|
+
try { renameSync(path, `${path}.unreadable`) } catch {}
|
|
202
|
+
console.error(`[session] slot file ${path}: history is not an array — preserved as ${basename(path)}.unreadable`)
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
402
205
|
data.history = data.history.filter((m) => !isLegacyTransient(m))
|
|
403
|
-
data._recovered = false
|
|
404
206
|
return data
|
|
405
207
|
} catch (e) {
|
|
406
208
|
return { _error: e }
|
|
407
209
|
}
|
|
408
210
|
}
|
|
409
211
|
|
|
410
|
-
// Try the active slot first (post-migration, legacy file may be stale)
|
|
411
|
-
const slot = activeSlot(cwd)
|
|
412
|
-
const p = slotPath(cwd, slot)
|
|
413
212
|
let result = tryLoad(p)
|
|
414
213
|
if (result && !result._error) return result
|
|
415
214
|
if (result?._error) {
|
|
@@ -417,36 +216,95 @@ export function loadSession(cwd) {
|
|
|
417
216
|
const tmpResult = tryLoad(`${p}.tmp`)
|
|
418
217
|
if (tmpResult && !tmpResult._error) {
|
|
419
218
|
console.error(`[session] recovered from .tmp fallback`)
|
|
420
|
-
|
|
219
|
+
// 2026-09-01 会诊 🟢:.tmp 提升为正主(损坏主文件改名 .corrupted 保留现场)——
|
|
220
|
+
// 否则主文件留在原地,每次加载都重复"恢复",且保存侧 F2 会持续轮转它。
|
|
221
|
+
try {
|
|
222
|
+
renameSync(p, `${p}.corrupted`)
|
|
223
|
+
renameSync(`${p}.tmp`, p)
|
|
224
|
+
} catch {}
|
|
421
225
|
return tmpResult
|
|
422
226
|
}
|
|
423
227
|
console.error(`[session] .tmp fallback also failed — session lost.`)
|
|
424
228
|
try { renameSync(p, `${p}.corrupted`) } catch {}
|
|
229
|
+
} else if (!result) {
|
|
230
|
+
// 2026-09-01 advisor 🔵:主文件缺失但孤儿 .tmp 存在(原子写 rename 前崩溃)——
|
|
231
|
+
// 恢复并提升为正主(SESSION.md §4 的 .tmp 回退语义应覆盖此场景)。
|
|
232
|
+
const orphan = tryLoad(`${p}.tmp`)
|
|
233
|
+
if (orphan && !orphan._error) {
|
|
234
|
+
console.error(`[session] slot ${slot} main file missing — recovered orphan .tmp`)
|
|
235
|
+
try { renameSync(`${p}.tmp`, p) } catch {}
|
|
236
|
+
return orphan
|
|
237
|
+
}
|
|
425
238
|
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
|
|
243
|
+
export function loadSession(cwd) {
|
|
244
|
+
// Try the active slot first (post-migration, legacy file may be stale)
|
|
245
|
+
const slot = activeSlot(cwd)
|
|
246
|
+
let result = loadSlotFile(cwd, slot)
|
|
247
|
+
if (result) return result
|
|
426
248
|
|
|
427
249
|
// Fallback: try the legacy current file (pre-migration, or migration failed to clean up)
|
|
428
250
|
const legacy = sessionPath(cwd)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
251
|
+
try {
|
|
252
|
+
if (existsSync(legacy)) {
|
|
253
|
+
const data = JSON.parse(readFileSync(legacy, "utf8"))
|
|
254
|
+
// cwd 不匹配是别人的文件——与 loadSlotFile 一致直接 return null 不改名
|
|
255
|
+
// ("别人的文件不动"原则,2026-08-31 advisor round2 🟡)
|
|
256
|
+
if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
|
|
257
|
+
if ((data?.version === 1 || data?.version === 2) && Array.isArray(data.history)) {
|
|
258
|
+
data.history = data.history.filter((m) => !isLegacyTransient(m))
|
|
259
|
+
return data
|
|
260
|
+
}
|
|
261
|
+
// 结构不匹配:保留现场(version>2 的新版文件不动)
|
|
262
|
+
if (!(typeof data?.version === "number" && data.version > 2)) {
|
|
263
|
+
try { renameSync(legacy, `${legacy}.unreadable`) } catch {}
|
|
264
|
+
console.error(`[session] legacy file ${legacy}: invalid structure — preserved as ${basename(legacy)}.unreadable`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {
|
|
268
|
+
console.error(`[session] failed to load legacy ${legacy}: ${e.message}`)
|
|
435
269
|
try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
|
|
436
270
|
}
|
|
437
|
-
|
|
438
271
|
return null
|
|
439
272
|
}
|
|
440
273
|
|
|
441
|
-
/**
|
|
274
|
+
/** slimForDisplay 截断的 arguments 以 U+2026(…)结尾——不是合法 JSON 的完整值。
|
|
275
|
+
* v1 老文件回退播种机器线时置为 {}(合法空参数),防止半截 \\uXXXX 毒化发送载荷(会诊 F6)。 */
|
|
276
|
+
function stripTruncatedToolArgs(m) {
|
|
277
|
+
if (m?.role !== "assistant" || !Array.isArray(m.tool_calls)) return m
|
|
278
|
+
let changed = false
|
|
279
|
+
const tool_calls = m.tool_calls.map((tc) => {
|
|
280
|
+
const args = tc?.function?.arguments
|
|
281
|
+
if (typeof args === "string" && args.endsWith("…")) {
|
|
282
|
+
changed = true
|
|
283
|
+
return { ...tc, function: { ...tc.function, arguments: "{}" } }
|
|
284
|
+
}
|
|
285
|
+
return tc
|
|
286
|
+
})
|
|
287
|
+
return changed ? { ...m, tool_calls } : m
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Apply loaded session data onto an agent object; returns true if provider was switched.
|
|
291
|
+
* 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领(F1b 回归:
|
|
292
|
+
* switchToSlot 后保存必须落新槽而非旧槽)。 */
|
|
442
293
|
export function applySession(agent, data) {
|
|
443
294
|
// data.history is the FULL never-compacted record (human line); data.contextHistory is the
|
|
444
295
|
// (possibly compacted) machine line. Restore each line from its own source — the machine
|
|
445
296
|
// context keeps its compaction savings across resume. Legacy files without contextHistory
|
|
446
297
|
// fall back to seeding the machine line from the full history (it re-compacts when needed).
|
|
298
|
+
// 2026-08-31 会诊 deepseek 🟡:机读线必须从 contextHistory 恢复而非从完整 history 重建——
|
|
299
|
+
// 后者会把已压缩的中间过程塞回上下文(实测 prompt 膨胀到 283%)。compactThresholdAuto 时
|
|
300
|
+
// 按恢复后的模型重新推导阈值(bin/thincoder.mjs)。v1 老文件(无 contextHistory)回退
|
|
301
|
+
// 播种时剥离被 slimForDisplay 截断的 tool_calls.arguments(以 … 结尾 → 置 {};会诊 F6——
|
|
302
|
+
// 截断可劈断 \\uXXXX 产生 400 毒载荷)。2026-09-01 会诊三家:length > 0 才当机读线
|
|
303
|
+
// (contextHistory: [] 是"无机读线"而非空机器线——空机器线会静默丢全部上下文)。
|
|
447
304
|
agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
|
|
448
305
|
const full = Array.isArray(data.history) ? data.history : []
|
|
449
|
-
const
|
|
306
|
+
const ch = data.contextHistory
|
|
307
|
+
const machine = (Array.isArray(ch) && ch.length > 0) ? ch : full.map(stripTruncatedToolArgs)
|
|
450
308
|
agent._fullHistory = [...full]
|
|
451
309
|
agent.history = [...machine]
|
|
452
310
|
agent.title = data.title ?? ""
|
|
@@ -471,6 +329,8 @@ export function applySession(agent, data) {
|
|
|
471
329
|
agent._compressFailures = 0
|
|
472
330
|
agent._verifyRetries = 0
|
|
473
331
|
agent._verifyPassed = false
|
|
332
|
+
agent._slot = null // 粘性缓存清空——切换后重新认领(F1b)
|
|
333
|
+
agent._slotMtime = null
|
|
474
334
|
if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
|
|
475
335
|
const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
|
|
476
336
|
if (p) {
|
|
@@ -502,18 +362,119 @@ export function applySession(agent, data) {
|
|
|
502
362
|
*/
|
|
503
363
|
export function newSession(cwd) {
|
|
504
364
|
const m = loadManifest(cwd)
|
|
505
|
-
// Ensure active is set (migrate if needed)
|
|
506
|
-
if (!m.active) ensureActive(cwd, m)
|
|
507
365
|
|
|
508
|
-
//
|
|
366
|
+
// 2026-09-01 advisor 🔵:先清理死主条目(与 ensureActive 分支2 语义一致)——否则
|
|
367
|
+
// "死主 + 文件缺失"的空槽(m.slots 有条目、无文件、属主已死)永不复用,槽号持续
|
|
368
|
+
// 增长。死主且无文件 = 该会话从未落盘(进程死了没保存),条目可安全删除回收。
|
|
369
|
+
// 2026-09-01 会诊 deepseek/glm 🟡:清理必须经 deletions 显式落盘——saveManifest 条目级
|
|
370
|
+
// 合并会把磁盘死条目从 fresh 复活回写,仅传 m 等于没删(VS Code newSlot 已修,CLI 对称)。
|
|
371
|
+
const mySessionId = getSessionId()
|
|
372
|
+
const deadSlots = []
|
|
373
|
+
for (const [s, owner] of Object.entries(m.slotSessions ?? {})) {
|
|
374
|
+
if (owner && owner !== mySessionId) {
|
|
375
|
+
const pid = parseInt(owner.split("-")[0])
|
|
376
|
+
if (!pid || !isProcessAlive(pid)) {
|
|
377
|
+
delete m.slotSessions[s]
|
|
378
|
+
if (!existsSync(slotPath(cwd, Number(s)))) delete m.slots[s]
|
|
379
|
+
deadSlots.push(s)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Find next available slot number — 2026-08-31 会诊 deepseek 🟡:不能只看 manifest
|
|
385
|
+
// 条目(丢失更新可能让条目消失而文件仍在)——复用该号会直接覆写真实会话
|
|
386
|
+
// (F2 防护不覆盖 newSession 的空数据直写)。
|
|
387
|
+
// 2026-08-31 advisor round2 🟡:同时跳过"已被另一活进程认领但尚未落盘"的号
|
|
388
|
+
// (slotSessions 有条目、slots 无条目、文件不存在)——否则双进程会认领同一号。
|
|
389
|
+
const liveClaimed = (n) => {
|
|
390
|
+
const owner = m.slotSessions?.[n]
|
|
391
|
+
return !!(owner && owner !== mySessionId && isProcessAlive(parseInt(owner.split("-")[0])))
|
|
392
|
+
}
|
|
509
393
|
let slot = 1
|
|
510
|
-
while (m.slots[slot]) slot++
|
|
394
|
+
while (m.slots[slot] || existsSync(slotPath(cwd, slot)) || liveClaimed(slot)) slot++
|
|
511
395
|
|
|
512
|
-
// Write empty session
|
|
513
|
-
|
|
396
|
+
// Write empty session — 2026-09-01 advisor 🔵:补 contextHistory/planMode 字段与
|
|
397
|
+
// VS Code newSlot 对齐(SESSION.md §3 v2 格式双端一致;两端读侧均有兜底,功能等价)
|
|
398
|
+
const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], contextHistory: [], tasks: [], planMode: false, goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
|
|
514
399
|
writeSessionFile(slotPath(cwd, slot), data)
|
|
515
400
|
m.slots[slot] = slotDigest(data)
|
|
516
401
|
m.active = slot
|
|
517
|
-
|
|
402
|
+
// 2026-08-31 advisor round1 🟡:与 ensureActive 认领模型一致——立即记录新 slot 所有权,
|
|
403
|
+
// 否则 /new 后到首次保存之间并发方(VS Code/另一 CLI)会把新 active 槽当"空闲可恢复"
|
|
404
|
+
// 认领 → 双进程写同一槽(F2 轮转互旋)。
|
|
405
|
+
m.slotSessions ??= {}
|
|
406
|
+
m.slotSessions[slot] = mySessionId
|
|
407
|
+
// 2026-09-01 会诊三家 🟡:显式翻 active 的调用点传 setActive(saveManifest 默认保留
|
|
408
|
+
// 磁盘 fresh.active,避免把并发方刚翻的指针回滚)
|
|
409
|
+
// 2026-09-01 会诊 deepseek/glm 🟡:deletions 过滤掉本调用刚重新认领的槽(防删掉自己的
|
|
410
|
+
// 新属主)——与 ensureActive deadParam / VS Code newSlot 同型。
|
|
411
|
+
const deletions = deadSlots.length
|
|
412
|
+
? {
|
|
413
|
+
slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId),
|
|
414
|
+
slots: deadSlots.filter((s) => !m.slots[s]),
|
|
415
|
+
}
|
|
416
|
+
: null
|
|
417
|
+
saveManifest(cwd, m, deletions, { setActive: true })
|
|
518
418
|
return slot
|
|
519
419
|
}
|
|
420
|
+
|
|
421
|
+
/** 清空会话运行态(/new 用,2026-08-31 会诊 F3):_fullHistory/title/_sessionStart 等
|
|
422
|
+
* 全部会话级状态 + 一次性注入标志必须全清——否则新会话首次落盘把旧会话完整人类线 +
|
|
423
|
+
* 旧标题写进新槽(实锤 .19/.3 双副本);注入标志不清则 /new 后新会话永不注入
|
|
424
|
+
* OS/cwd reminder(2026-09-01 会诊 glm 🟡)。autoApprove 是用户偏好,跨会话保留(有意)。 */
|
|
425
|
+
export function resetSessionState(agent) {
|
|
426
|
+
agent._fullHistory = []
|
|
427
|
+
agent.history = []
|
|
428
|
+
agent.title = ""
|
|
429
|
+
agent.tasks = []
|
|
430
|
+
agent._sessionStart = null
|
|
431
|
+
agent._engDesignToken = null
|
|
432
|
+
agent._compressFailures = 0
|
|
433
|
+
agent._verifyRetries = 0
|
|
434
|
+
agent._verifyPassed = undefined
|
|
435
|
+
agent._runStartHistoryLen = 0
|
|
436
|
+
agent._lastPromptTokens = null
|
|
437
|
+
agent._usageAtLen = null
|
|
438
|
+
agent.planMode = false
|
|
439
|
+
agent.goal = null
|
|
440
|
+
agent._pendingReminders = []
|
|
441
|
+
agent._slot = null
|
|
442
|
+
agent._slotMtime = null
|
|
443
|
+
agent._osReminderInjected = false
|
|
444
|
+
agent._restartReminderInjected = false
|
|
445
|
+
agent._lastEngState = false
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Switch the manifest active pointer to a slot. Returns the slot's session data
|
|
449
|
+
* (null if the slot doesn't exist / can't be read). 2026-08-31 会诊 deepseek 🔴:
|
|
450
|
+
* 原实现经 loadSession → activeSlot 有认领副作用——目标槽被活进程占用时 ensureActive
|
|
451
|
+
* 分支 3 会把 active 拨到新空槽并读回 null + 劫持对方指针。现改为 loadSlotFile 直接读
|
|
452
|
+
* (无认领副作用);目标槽空闲则一并认领、被另一活进程占用则不认领(slotOccupancy——
|
|
453
|
+
* 下次保存经 activeSlot 自然 fork 到新槽);只改 manifest 指针(setActive 意图)。
|
|
454
|
+
* 2026-09-01 会诊三家 🟡:saveManifest 条目级合并 + setActive(不把并发方刚翻的指针回滚)。 */
|
|
455
|
+
export function switchToSlot(cwd, slot) {
|
|
456
|
+
const m = loadManifest(cwd)
|
|
457
|
+
if (!m.slots[slot]) return null
|
|
458
|
+
const data = loadSlotFile(cwd, slot)
|
|
459
|
+
if (!data) return null
|
|
460
|
+
m.active = slot
|
|
461
|
+
const occ = slotOccupancy(cwd, slot)
|
|
462
|
+
if (!occ.occupied) {
|
|
463
|
+
m.slotSessions ??= {}
|
|
464
|
+
m.slotSessions[slot] = getSessionId()
|
|
465
|
+
}
|
|
466
|
+
saveManifest(cwd, m, null, { setActive: true })
|
|
467
|
+
return data
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 目标槽是否被另一活进程占用(2026-09-01 会诊/advisor 🟡):排除本进程属主——
|
|
471
|
+
* /session 重选当前槽不误报;同进程双会话防护由 ACP sameProcessPinned 承担。 */
|
|
472
|
+
export function slotOccupancy(cwd, slot) {
|
|
473
|
+
const m = loadManifest(cwd)
|
|
474
|
+
const owner = m.slotSessions?.[slot]
|
|
475
|
+
if (!owner) return { occupied: false }
|
|
476
|
+
if (owner === getSessionId()) return { occupied: false }
|
|
477
|
+
const pid = parseInt(owner.split("-")[0])
|
|
478
|
+
if (!pid || !isProcessAlive(pid)) return { occupied: false }
|
|
479
|
+
return { occupied: true, owner }
|
|
480
|
+
}
|
package/src/tools/bash.md
CHANGED
|
@@ -27,9 +27,9 @@ Output format:
|
|
|
27
27
|
Notes:
|
|
28
28
|
- There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
|
|
29
29
|
- The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
|
|
30
|
-
- Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors.
|
|
30
|
+
- Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors, so read the saved log file's tail before trusting success. Use `filter` to narrow instead of hand-piping.
|
|
31
31
|
- Check `[stderr]` for error messages, warnings, and diagnostic output — it is separated from `[stdout]` so you can quickly identify problems.
|
|
32
|
-
- On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)
|
|
32
|
+
- On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)`, `${VAR}`, single quotes, `;` separators) FAIL — the tool prepends a hint when it detects POSIX-only syntax, and still executes. For complex logic prefer the execute tool (node) over shell gymnastics.
|
|
33
33
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
34
34
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
35
35
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|