thincoder 0.12.5 → 0.12.6
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 +9 -1
- package/package.json +1 -1
- package/src/context.mjs +0 -17
- package/src/git/checkpoint.mjs +172 -107
- package/src/session.mjs +29 -10
- package/src/tools/git.mjs +21 -10
- package/src/tools/system.mjs +79 -24
- package/src/tui/agent-turn.mjs +0 -8
- package/src/tui/clipboard.mjs +20 -0
- package/src/tui/index.mjs +14 -0
- package/src/tui/mouse.mjs +117 -0
- package/src/tui/pickers.mjs +1 -1
- package/src/tui/render-conversation.mjs +22 -3
package/README.md
CHANGED
|
@@ -174,7 +174,8 @@ src/
|
|
|
174
174
|
config.mjs config loading
|
|
175
175
|
tui/ bare-ANSI terminal UI — index.mjs (startTUI), render.mjs (drawing primitives),
|
|
176
176
|
render-frame.mjs (frame layout), render-conversation.mjs (conversation panel),
|
|
177
|
-
markdown.mjs (lightweight inline markdown → ANSI),
|
|
177
|
+
markdown.mjs (lightweight inline markdown → ANSI), mouse.mjs (SGR clicks),
|
|
178
|
+
clipboard.mjs (paste/copy), ansi.mjs
|
|
178
179
|
tui.mjs re-export shim → src/tui/index.mjs
|
|
179
180
|
tui-render.mjs re-export shim → src/tui/render.mjs
|
|
180
181
|
prompts/ prompt texts — system.md (core), discipline.md (coding/testing rules),
|
|
@@ -208,6 +209,13 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
208
209
|
|
|
209
210
|
## Changelog
|
|
210
211
|
|
|
212
|
+
### 0.12.6 (2026-08)
|
|
213
|
+
- **Checkpoint v2 — full-file-copy snapshots** — snapshots now store complete copies of changed files (tracked + untracked) instead of a git diff patch: rollback works even after commits happened post-snapshot. New `versions` checkpoint action lists a file's historical copies across snapshots (time / size / content hash) and restores a specific version. **Full rollback is disabled** (as dangerous as a working-tree reset — silently discards post-snapshot work); oversized files (>5MB) are skipped with an explicit notice; files created after a snapshot are never deleted by a restore.
|
|
214
|
+
- **Git destructive-command protection** — `checkout --` / `restore` / `reset --hard` / `clean -f` (including bypass variants like `checkout HEAD -- .`) auto-snapshot every uncommitted file **before** running, then execute without blocking — a model rollback can no longer destroy uncommitted work. Snapshot triggers slimmed to: destructive-git guard, pre-restore, manual.
|
|
215
|
+
- **Fix: bash no longer hangs on background processes** — `start /b`, `&`, `nohup` children that hold the output pipe no longer stall the tool until timeout (resolves after a short grace with a notice).
|
|
216
|
+
- **Mouse support** (SGR-protocol terminals) — click a picker option to select it, click a folded-block hint to expand it, click a message line for an action menu (copy to clipboard / load into the input box). **Long messages (>12 wrapped lines) auto-fold** to first/hint/last with click-to-expand — folding now has real objects in everyday sessions.
|
|
217
|
+
- **Fix: legacy session hash migration** — the 12→40 char hash migration now tries every historical algorithm (CLI 12-char, VS Code 16-char, both drive-letter cases): Windows sessions from older versions are found and migrated on next startup instead of being stranded.
|
|
218
|
+
|
|
211
219
|
### 0.12.5 (2026-08)
|
|
212
220
|
- **Fix: inline code styling** — markdown code spans now render with underline instead of reverse video (less harsh on the eyes)
|
|
213
221
|
|
package/package.json
CHANGED
package/src/context.mjs
CHANGED
|
@@ -211,25 +211,8 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {})
|
|
|
211
211
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
212
212
|
})
|
|
213
213
|
|
|
214
|
-
// Auto-checkpoint before compaction: snapshot current state so the model can
|
|
215
|
-
// reconstruct context from git diff + recent messages + task progress later.
|
|
216
|
-
let cpId = null
|
|
217
|
-
try {
|
|
218
|
-
const { createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
219
|
-
const cp = await createCheckpoint(agent.cwd)
|
|
220
|
-
cpId = cp?.id
|
|
221
|
-
} catch { /* checkpoint might fail — compaction itself should not be blocked */ }
|
|
222
|
-
|
|
223
214
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
224
215
|
|
|
225
|
-
// Inject checkpoint reference after compaction so the model knows it can use /restore
|
|
226
|
-
if (cpId) {
|
|
227
|
-
agent.history.splice(split.headEnd, 0, {
|
|
228
|
-
role: "user",
|
|
229
|
-
content: `[System: context compacted. A checkpoint (id: ${cpId}) was auto-created before compaction. Use the checkpoint tool to review pre-compaction state if needed. File changes since then are tracked in git diff.]`,
|
|
230
|
-
})
|
|
231
|
-
}
|
|
232
|
-
|
|
233
216
|
return true
|
|
234
217
|
}
|
|
235
218
|
|
package/src/git/checkpoint.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* checkpoint.mjs — workspace snapshot and rollback
|
|
3
|
-
* Snapshot =
|
|
2
|
+
* checkpoint.mjs — workspace snapshot and rollback (v2: full-file copies)
|
|
3
|
+
* Snapshot = full copies of changed tracked files + untracked file copies (respects .gitignore).
|
|
4
|
+
* v1 stored only a git diff patch — rewind depended on HEAD being unchanged (a commit after the
|
|
5
|
+
* snapshot made `git apply` fail and the failure recovery chain collapse). v2 copies files, so
|
|
6
|
+
* rewind works regardless of later commits.
|
|
4
7
|
* Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
|
|
5
|
-
* rewind supports a path parameter for per-file restore
|
|
8
|
+
* rewind supports a path parameter for per-file restore.
|
|
6
9
|
*/
|
|
7
10
|
import { execFileSync } from "node:child_process"
|
|
8
11
|
import { createHash } from "node:crypto"
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
10
|
-
import { cp, mkdir, readFile, readdir, rm, writeFile
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
13
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
|
11
14
|
import { dirname, join } from "node:path"
|
|
12
15
|
import { configDir } from "../config.mjs"
|
|
13
16
|
|
|
@@ -15,6 +18,12 @@ const CWD_HASH_LEN = 12
|
|
|
15
18
|
|
|
16
19
|
const MAX_CHECKPOINTS = 20
|
|
17
20
|
|
|
21
|
+
/** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
|
|
22
|
+
const MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
/** meta.version 2 = full-copy snapshots; 1 = legacy patch-only snapshots */
|
|
25
|
+
const META_VERSION = 2
|
|
26
|
+
|
|
18
27
|
function git(cwd, args, { allowFail = false } = {}) {
|
|
19
28
|
try {
|
|
20
29
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
|
@@ -56,7 +65,28 @@ export function isGitRepo(cwd) {
|
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
/**
|
|
59
|
-
*
|
|
68
|
+
* Copy a file into a snapshot directory, skipping oversized files.
|
|
69
|
+
* Returns { size, sha } (sha = SHA256 hex, 12 chars) or null when skipped/failed.
|
|
70
|
+
*/
|
|
71
|
+
async function copyInto(dir, rel, src, skipped) {
|
|
72
|
+
let size
|
|
73
|
+
try { size = statSync(src).size } catch { return null }
|
|
74
|
+
if (size > MAX_FILE_BYTES) {
|
|
75
|
+
skipped.push(rel)
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
const buf = await readFile(src).catch((e) => { console.error(`[checkpoint] skipping ${rel}: ${e.message}`); return null })
|
|
79
|
+
if (!buf) return null
|
|
80
|
+
const dst = join(dir, rel)
|
|
81
|
+
await mkdir(dirname(dst), { recursive: true })
|
|
82
|
+
await writeFile(dst, buf)
|
|
83
|
+
return { size: buf.length, sha: createHash("sha256").update(buf).digest("hex").slice(0, 12) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a snapshot. Returns { id, time, files, tracked, untracked, skipped } or null (non-git repo).
|
|
88
|
+
* Per-file metadata ({ size, sha } per copied file) is stored in meta.json — this powers
|
|
89
|
+
* listFileVersions (per-file history across snapshots) without rescanning copies.
|
|
60
90
|
*/
|
|
61
91
|
export async function createCheckpoint(cwd) {
|
|
62
92
|
if (!isGitRepo(cwd)) return null
|
|
@@ -64,30 +94,42 @@ export async function createCheckpoint(cwd) {
|
|
|
64
94
|
// Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
|
|
65
95
|
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
|
|
66
96
|
const dir = join(checkpointRoot(cwd), id)
|
|
97
|
+
await mkdir(join(dir, "files"), { recursive: true })
|
|
67
98
|
await mkdir(join(dir, "untracked"), { recursive: true })
|
|
68
99
|
|
|
69
|
-
//
|
|
100
|
+
// v2: full copies of changed tracked files (git apply of a stale patch is the v1 failure mode).
|
|
101
|
+
// The patch is still written for legacy tooling/debugging, but rewind uses the copies.
|
|
102
|
+
const head = git(cwd, ["rev-parse", "HEAD"], { allowFail: true }) ?? ""
|
|
103
|
+
const changedRaw = git(cwd, ["diff", "HEAD", "--name-only", "-z"], { allowFail: true }) ?? ""
|
|
104
|
+
const changed = changedRaw ? changedRaw.split("\0").filter(Boolean) : []
|
|
105
|
+
const trackedAllRaw = git(cwd, ["ls-files", "-z"], { allowFail: true }) ?? ""
|
|
106
|
+
const trackedAll = trackedAllRaw ? trackedAllRaw.split("\0").filter(Boolean) : []
|
|
70
107
|
const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
|
|
71
108
|
await writeFile(join(dir, "patch.diff"), patch, "utf8")
|
|
72
|
-
|
|
109
|
+
|
|
110
|
+
const skipped = []
|
|
111
|
+
const fileMeta = {}
|
|
112
|
+
for (const rel of changed) {
|
|
113
|
+
const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
|
|
114
|
+
if (m) fileMeta[rel] = m
|
|
115
|
+
}
|
|
73
116
|
|
|
74
117
|
// Untracked files (respects .gitignore) → copy as-is
|
|
75
118
|
const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
|
|
76
119
|
const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
|
|
120
|
+
const untrackedMeta = {}
|
|
77
121
|
for (const rel of untracked) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
await mkdir(dirname(dst), { recursive: true })
|
|
81
|
-
// Copy failed (socket/device file etc.) — skip, but log in case it's unexpected
|
|
82
|
-
await copyFile(src, dst).catch((e) => console.error(`[checkpoint] skipping ${rel}: ${e.message}`))
|
|
122
|
+
const m = await copyInto(join(dir, "untracked"), rel, join(cwd, rel), skipped)
|
|
123
|
+
if (m) untrackedMeta[rel] = m
|
|
83
124
|
}
|
|
84
125
|
|
|
85
126
|
await writeFile(join(dir, "meta.json"), JSON.stringify({
|
|
86
|
-
id, time: Date.now(), untracked, tracked,
|
|
127
|
+
version: META_VERSION, id, time: Date.now(), untracked, tracked: changed, skipped,
|
|
128
|
+
head, trackedAll, fileMeta, untrackedMeta,
|
|
87
129
|
}, null, 2), "utf8")
|
|
88
130
|
|
|
89
131
|
await pruneCheckpoints(cwd)
|
|
90
|
-
return { id, time: Date.now(), files:
|
|
132
|
+
return { id, time: Date.now(), files: changed.length + untracked.length, tracked: changed, untracked, skipped }
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
/** List checkpoints (newest→oldest), with file change summary */
|
|
@@ -108,6 +150,7 @@ export async function listCheckpoints(cwd) {
|
|
|
108
150
|
out.push({
|
|
109
151
|
id, time: meta.time,
|
|
110
152
|
untracked: meta.untracked ?? [],
|
|
153
|
+
skipped: meta.skipped ?? [],
|
|
111
154
|
tracked,
|
|
112
155
|
})
|
|
113
156
|
} catch {
|
|
@@ -119,49 +162,12 @@ export async function listCheckpoints(cwd) {
|
|
|
119
162
|
|
|
120
163
|
// ---- restore core ----
|
|
121
164
|
|
|
122
|
-
/**
|
|
123
|
-
async function
|
|
124
|
-
git(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "."])
|
|
125
|
-
const patch = await readFile(join(dir, "patch.diff"), "utf8")
|
|
126
|
-
if (patch.trim()) {
|
|
127
|
-
git(cwd, ["apply", "--whitespace=nowarn", join(dir, "patch.diff")])
|
|
128
|
-
}
|
|
129
|
-
return Boolean(patch.trim())
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Full restore of untracked files from a checkpoint: delete new ones → restore from snapshot */
|
|
133
|
-
async function fullRestoreUntracked(cwd, dir, meta) {
|
|
134
|
-
const nowUntracked = (git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? "")
|
|
135
|
-
.split("\n")
|
|
136
|
-
.filter(Boolean)
|
|
137
|
-
const checkpointSet = new Set(meta.untracked)
|
|
138
|
-
let deleted = 0
|
|
139
|
-
for (const rel of nowUntracked) {
|
|
140
|
-
if (!checkpointSet.has(rel)) {
|
|
141
|
-
await rm(join(cwd, rel), { force: true })
|
|
142
|
-
deleted++
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
let restored = 0
|
|
147
|
-
for (const rel of meta.untracked) {
|
|
148
|
-
const src = join(dir, "untracked", rel)
|
|
149
|
-
if (existsSync(src)) {
|
|
150
|
-
await mkdir(dirname(join(cwd, rel)), { recursive: true })
|
|
151
|
-
await cp(src, join(cwd, rel), { force: true })
|
|
152
|
-
restored++
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return { deleted, restored }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Restore a single tracked file: checkout HEAD → apply that file's patch hunks */
|
|
159
|
-
async function partialRestoreTracked(cwd, patchContent, filePath) {
|
|
160
|
-
// Reset the file to HEAD state first
|
|
165
|
+
/** v1 legacy fallback: reset file to HEAD then apply its patch hunks. */
|
|
166
|
+
async function partialRestoreTrackedViaPatch(cwd, root, patchContent, filePath) {
|
|
161
167
|
git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
|
|
162
168
|
const hunks = extractFileHunks(patchContent, filePath)
|
|
163
169
|
if (!hunks) return false
|
|
164
|
-
const tmpFile = join(
|
|
170
|
+
const tmpFile = join(root, ".tmp_partial.patch")
|
|
165
171
|
await writeFile(tmpFile, hunks, "utf8")
|
|
166
172
|
try {
|
|
167
173
|
git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
|
|
@@ -171,85 +177,136 @@ async function partialRestoreTracked(cwd, patchContent, filePath) {
|
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
|
|
174
|
-
/** Restore a single
|
|
175
|
-
async function
|
|
176
|
-
const src = join(
|
|
180
|
+
/** Restore a single file from a snapshot copy. Returns true when the copy existed. */
|
|
181
|
+
async function restoreFromCopy(snapshotDir, rel, cwd) {
|
|
182
|
+
const src = join(snapshotDir, rel)
|
|
177
183
|
if (!existsSync(src)) return false
|
|
178
|
-
await mkdir(dirname(join(cwd,
|
|
179
|
-
await cp(src, join(cwd,
|
|
184
|
+
await mkdir(dirname(join(cwd, rel)), { recursive: true })
|
|
185
|
+
await cp(src, join(cwd, rel), { force: true })
|
|
180
186
|
return true
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
// ----
|
|
189
|
+
// ---- restore (single-file only) ----
|
|
184
190
|
|
|
185
191
|
/**
|
|
186
|
-
*
|
|
192
|
+
* Restore ONE file from a specific snapshot (saves current state as a new snapshot first,
|
|
193
|
+
* making the restore reversible).
|
|
187
194
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
195
|
+
* FULL restore is deliberately DISABLED: rolling the whole working tree back to a snapshot
|
|
196
|
+
* is exactly as dangerous as `git checkout -- .` — it silently discards every change made
|
|
197
|
+
* after the snapshot. Restore files individually (rewind with path / restoreFile).
|
|
191
198
|
*
|
|
192
|
-
* Returns
|
|
199
|
+
* Returns { path, type, restored }.
|
|
193
200
|
*/
|
|
194
201
|
export async function rewind(cwd, id, { path } = {}) {
|
|
202
|
+
if (!path) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
"Full rewind is disabled — it is as dangerous as `git checkout -- .` (silently discards all work after the snapshot). " +
|
|
205
|
+
"Restore files individually: rewind(cwd, id, { path }) or restoreFile(cwd, path, id)."
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
195
209
|
const root = checkpointRoot(cwd)
|
|
196
210
|
const dir = join(root, id)
|
|
197
211
|
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
198
212
|
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
213
|
+
const isV2 = meta.version === META_VERSION
|
|
199
214
|
const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
|
|
200
215
|
|
|
201
|
-
//
|
|
202
|
-
|
|
216
|
+
// Restore is reversible: snapshot current state first
|
|
217
|
+
await createCheckpoint(cwd)
|
|
203
218
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const inUntracked = (meta.untracked ?? []).includes(path)
|
|
219
|
+
const tracked = meta.tracked ?? []
|
|
220
|
+
const trackedAll = meta.trackedAll ?? []
|
|
221
|
+
const isTracked = tracked.includes(path) || trackedAll.includes(path) || extractFileHunks(patchContent, path) !== ""
|
|
222
|
+
const inUntracked = (meta.untracked ?? []).includes(path)
|
|
209
223
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
224
|
+
// Oversized files were never copied — nothing to restore, say so explicitly.
|
|
225
|
+
if ((meta.skipped ?? []).includes(path)) {
|
|
226
|
+
throw new Error(`file "${path}" was NOT snapshotted in checkpoint ${id} (oversized, >5MB) — no copy exists to restore`)
|
|
227
|
+
}
|
|
213
228
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
if (inUntracked) {
|
|
219
|
-
ok = await partialRestoreUntracked(cwd, dir, path) || ok
|
|
220
|
-
}
|
|
229
|
+
if (!isTracked && !inUntracked) {
|
|
230
|
+
throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${tracked.join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
|
|
231
|
+
}
|
|
221
232
|
|
|
222
|
-
|
|
233
|
+
let ok = false
|
|
234
|
+
if (inUntracked) {
|
|
235
|
+
ok = await restoreFromCopy(join(dir, "untracked"), path, cwd) || ok
|
|
236
|
+
}
|
|
237
|
+
if (isTracked) {
|
|
238
|
+
if (isV2 && existsSync(join(dir, "files", path))) {
|
|
239
|
+
ok = await restoreFromCopy(join(dir, "files"), path, cwd) || ok
|
|
240
|
+
} else if (isV2 && meta.head) {
|
|
241
|
+
// Untouched at snapshot time (content = snapshot HEAD) → checkout that commit's version.
|
|
242
|
+
// Works even if HEAD moved since — the commit object is immutable.
|
|
243
|
+
git(cwd, ["checkout", meta.head, "--", path], { allowFail: true })
|
|
244
|
+
ok = true
|
|
245
|
+
} else {
|
|
246
|
+
// v1 snapshot → patch fallback
|
|
247
|
+
ok = await partialRestoreTrackedViaPatch(cwd, root, patchContent, path) || ok
|
|
248
|
+
}
|
|
223
249
|
}
|
|
224
250
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
251
|
+
return { path, type: isTracked ? "tracked" : "untracked", restored: ok }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- per-file history ----
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* List every historical version of a file across all snapshots (newest first).
|
|
258
|
+
* Distinguishing copies: each entry carries its snapshot id, time, byte size and
|
|
259
|
+
* content sha — the same file in different snapshots is a different version.
|
|
260
|
+
* @param {string} cwd
|
|
261
|
+
* @param {string} filePath — relative path as stored in snapshots
|
|
262
|
+
* @returns {Promise<Array<{snapshotId: string, time: number, size: number, sha: string, source: "tracked"|"untracked"}>>}
|
|
263
|
+
*/
|
|
264
|
+
export async function listFileVersions(cwd, filePath) {
|
|
265
|
+
const root = checkpointRoot(cwd)
|
|
266
|
+
if (!existsSync(root)) return []
|
|
267
|
+
const ids = (await readdir(root)).sort().reverse() // newest → oldest
|
|
268
|
+
const out = []
|
|
269
|
+
for (const id of ids) {
|
|
270
|
+
let meta
|
|
271
|
+
try { meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8")) } catch { continue }
|
|
272
|
+
const trackedMeta = meta.fileMeta ?? {}
|
|
273
|
+
const untrackedMeta = meta.untrackedMeta ?? {}
|
|
274
|
+
let entry = null
|
|
275
|
+
if (trackedMeta[filePath]) {
|
|
276
|
+
entry = { snapshotId: id, time: meta.time, ...trackedMeta[filePath], source: "tracked" }
|
|
277
|
+
} else if (untrackedMeta[filePath]) {
|
|
278
|
+
entry = { snapshotId: id, time: meta.time, ...untrackedMeta[filePath], source: "untracked" }
|
|
279
|
+
} else if ((meta.tracked ?? []).includes(filePath) || (meta.untracked ?? []).includes(filePath)) {
|
|
280
|
+
// Legacy snapshot (no per-file meta): fall back to stat-ing the copy
|
|
281
|
+
const src = join((meta.tracked ?? []).includes(filePath) ? join(root, id, "files") : join(root, id, "untracked"), filePath)
|
|
282
|
+
let size = null, sha = null
|
|
283
|
+
try {
|
|
284
|
+
const buf = await readFile(src)
|
|
285
|
+
size = buf.length
|
|
286
|
+
sha = createHash("sha256").update(buf).digest("hex").slice(0, 12)
|
|
287
|
+
} catch { continue }
|
|
288
|
+
entry = { snapshotId: id, time: meta.time, size, sha, source: (meta.tracked ?? []).includes(filePath) ? "tracked" : "untracked" }
|
|
239
289
|
}
|
|
240
|
-
|
|
241
|
-
`Rewind to ${id} failed: ${e.message}. ` +
|
|
242
|
-
`The pre-rewind state was restored from checkpoint ${preRewindCp.id} — no work was lost.`
|
|
243
|
-
)
|
|
290
|
+
if (entry) out.push(entry)
|
|
244
291
|
}
|
|
292
|
+
return out
|
|
245
293
|
}
|
|
246
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Restore a single file's content from a specific snapshot (per-file restore —
|
|
297
|
+
* other files are left untouched). Thin wrapper over rewind path mode with clearer intent.
|
|
298
|
+
* @returns {Promise<{path: string, type: "tracked"|"untracked", restored: boolean}>}
|
|
299
|
+
*/
|
|
300
|
+
export async function restoreFile(cwd, filePath, snapshotId) {
|
|
301
|
+
return rewind(cwd, snapshotId, { path: filePath })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
247
305
|
// ---- view ----
|
|
248
306
|
|
|
249
307
|
/**
|
|
250
308
|
* View a file's content from a checkpoint (does not modify the working tree).
|
|
251
|
-
*
|
|
252
|
-
* Untracked files: read the copy directly from the checkpoint directory.
|
|
309
|
+
* v2: read the snapshot copy directly. Legacy snapshots fall back to the temporary-restore path.
|
|
253
310
|
* Returns the file content string.
|
|
254
311
|
*/
|
|
255
312
|
export async function catFile(cwd, id, filePath) {
|
|
@@ -257,23 +314,31 @@ export async function catFile(cwd, id, filePath) {
|
|
|
257
314
|
const dir = join(root, id)
|
|
258
315
|
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
259
316
|
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
317
|
+
const isV2 = meta.version === META_VERSION
|
|
260
318
|
const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
|
|
261
319
|
|
|
262
|
-
const
|
|
320
|
+
const tracked = meta.tracked ?? []
|
|
321
|
+
const isTracked = tracked.includes(filePath) || extractFileHunks(patchContent, filePath) !== ""
|
|
263
322
|
const inUntracked = (meta.untracked ?? []).includes(filePath)
|
|
264
323
|
|
|
265
324
|
if (!isTracked && !inUntracked) {
|
|
266
325
|
throw new Error(`file "${filePath}" not in checkpoint ${id}`)
|
|
267
326
|
}
|
|
268
327
|
|
|
269
|
-
//
|
|
328
|
+
// v2: read the snapshot copy directly
|
|
329
|
+
const copy = join(inUntracked ? join(dir, "untracked") : join(dir, "files"), filePath)
|
|
330
|
+
if (isV2 && existsSync(copy)) {
|
|
331
|
+
return await readFile(copy, "utf8")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Untracked legacy: read the copy if present
|
|
270
335
|
if (inUntracked && !isTracked) {
|
|
271
336
|
const src = join(dir, "untracked", filePath)
|
|
272
337
|
if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
|
|
273
338
|
return await readFile(src, "utf8")
|
|
274
339
|
}
|
|
275
340
|
|
|
276
|
-
// Tracked
|
|
341
|
+
// Tracked legacy: temporarily restore → read → restore working tree
|
|
277
342
|
const abs = join(cwd, filePath)
|
|
278
343
|
const existed = existsSync(abs)
|
|
279
344
|
let saved = null
|
package/src/session.mjs
CHANGED
|
@@ -35,19 +35,38 @@ function cwdHash(cwd) {
|
|
|
35
35
|
return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** One-time migration: rename legacy
|
|
39
|
-
* Idempotent; runs on first access per cwd.
|
|
38
|
+
/** One-time migration: rename legacy short-hash session files to the full 40-char hash.
|
|
39
|
+
* Idempotent; runs on first access per cwd.
|
|
40
|
+
* Historical hash algorithms (all sha1, none normalized the drive letter):
|
|
41
|
+
* - CLI: sha1(cwd).slice(0, 12) — cwd comes from process.cwd() (uppercase drive on Windows)
|
|
42
|
+
* - VS Code: sha1(cwd).slice(0, 16) — cwd comes from uri.fsPath (LOWERCASE drive on Windows)
|
|
43
|
+
* Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
|
|
44
|
+
* Every combination is tried — a migration that only checks one candidate misses real
|
|
45
|
+
* legacy files (drive-letter case differs between CLI and VS Code historical paths). */
|
|
40
46
|
function migrateHashLength(cwd, fullHash) {
|
|
41
47
|
const dir = join(configDir, "sessions")
|
|
42
|
-
const
|
|
43
|
-
|
|
48
|
+
const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
|
|
49
|
+
const candidates = [
|
|
50
|
+
createHash("sha1").update(cwd).digest("hex").slice(0, 12),
|
|
51
|
+
createHash("sha1").update(cwd).digest("hex").slice(0, 16),
|
|
52
|
+
createHash("sha1").update(lower).digest("hex").slice(0, 12),
|
|
53
|
+
createHash("sha1").update(lower).digest("hex").slice(0, 16),
|
|
54
|
+
fullHash.slice(0, 12),
|
|
55
|
+
]
|
|
44
56
|
const newBase = join(dir, `${fullHash}.json`)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
let migrated = false
|
|
58
|
+
for (const short of new Set(candidates)) {
|
|
59
|
+
const legacyBase = join(dir, `${short}.json`)
|
|
60
|
+
if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) continue
|
|
61
|
+
migrated = true
|
|
62
|
+
try {
|
|
63
|
+
for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
|
|
64
|
+
const from = legacyBase + suffix
|
|
65
|
+
if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
|
|
66
|
+
}
|
|
67
|
+
} catch { /* best-effort; leave files in place on failure */ }
|
|
68
|
+
}
|
|
69
|
+
return migrated
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
|
package/src/tools/git.mjs
CHANGED
|
@@ -16,12 +16,12 @@ export const gitTool = {
|
|
|
16
16
|
action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
|
|
17
17
|
// diff/log params
|
|
18
18
|
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
19
|
-
path: { type: "string", description: "(diff/log/checkpoint:cat/
|
|
19
|
+
path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind) File or directory to scope to" },
|
|
20
20
|
ref: { type: "string", description: "(diff) Compare against this ref (default HEAD)" },
|
|
21
21
|
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
22
22
|
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
23
23
|
// checkpoint params
|
|
24
|
-
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot" },
|
|
24
|
+
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
|
|
25
25
|
checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
|
|
26
26
|
},
|
|
27
27
|
required: ["action"],
|
|
@@ -81,23 +81,34 @@ export const gitTool = {
|
|
|
81
81
|
return truncate(out || "(no commits)")
|
|
82
82
|
}
|
|
83
83
|
case "checkpoint": {
|
|
84
|
-
const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
84
|
+
const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
85
85
|
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
86
86
|
|
|
87
87
|
const sub = args.checkpointAction
|
|
88
|
-
if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat"
|
|
88
|
+
if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
|
|
89
89
|
|
|
90
90
|
if (sub === "create") {
|
|
91
91
|
const cp = await createCheckpoint(ctx.cwd)
|
|
92
92
|
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
93
93
|
}
|
|
94
|
+
if (sub === "versions") {
|
|
95
|
+
if (!args.path) throw new Error("path is required for versions — the file whose history you want")
|
|
96
|
+
const versions = await listFileVersions(ctx.cwd, args.path)
|
|
97
|
+
if (versions.length === 0) return `No snapshot copies of "${args.path}" found (it was never part of an auto/protection snapshot).`
|
|
98
|
+
return (
|
|
99
|
+
`Historical versions of "${args.path}" (${versions.length}, newest first):\n` +
|
|
100
|
+
versions.map((v) =>
|
|
101
|
+
` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
|
|
102
|
+
(v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
|
|
103
|
+
).join("\n") +
|
|
104
|
+
`\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${args.path}"`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
94
107
|
if (sub === "rewind") {
|
|
95
108
|
if (!args.checkpointId) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
|
|
109
|
+
if (!args.path) throw new Error("path is required for rewind — full restore is disabled (as dangerous as `git checkout -- .`). Restore files individually. Use checkpointAction=versions path=<file> to list a file's historical versions.")
|
|
96
110
|
const s = await rewind(ctx.cwd, args.checkpointId, { path: args.path })
|
|
97
|
-
|
|
98
|
-
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.checkpointId}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
99
|
-
}
|
|
100
|
-
return `Rewound to checkpoint ${args.checkpointId}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored ?? 0} untracked file(s) restored, ${s.deleted ?? 0} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
111
|
+
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.checkpointId}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
|
|
101
112
|
}
|
|
102
113
|
if (sub === "cat") {
|
|
103
114
|
if (!args.checkpointId) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
|
|
@@ -107,7 +118,7 @@ export const gitTool = {
|
|
|
107
118
|
}
|
|
108
119
|
if (sub === "list") {
|
|
109
120
|
const cps = await listCheckpoints(ctx.cwd)
|
|
110
|
-
if (cps.length === 0) return "(no checkpoints yet
|
|
121
|
+
if (cps.length === 0) return "(no checkpoints yet)"
|
|
111
122
|
|
|
112
123
|
// Specific id: show the file tree within that snapshot
|
|
113
124
|
if (args.checkpointId) {
|
|
@@ -125,7 +136,7 @@ export const gitTool = {
|
|
|
125
136
|
return parts.join(" ")
|
|
126
137
|
}).join("\n")
|
|
127
138
|
}
|
|
128
|
-
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat`)
|
|
139
|
+
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
|
|
129
140
|
}
|
|
130
141
|
default:
|
|
131
142
|
return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
|
package/src/tools/system.mjs
CHANGED
|
@@ -7,10 +7,8 @@ import {
|
|
|
7
7
|
IGNORED_DIRS,
|
|
8
8
|
resolveInCwd,
|
|
9
9
|
shellSegments,
|
|
10
|
-
isDestructiveGitSegment,
|
|
11
10
|
isDestructiveCommand,
|
|
12
11
|
hasFileRedirection,
|
|
13
|
-
insideGitRepo,
|
|
14
12
|
globToRegex,
|
|
15
13
|
normalizeEOL,
|
|
16
14
|
} from "./shared.mjs";
|
|
@@ -27,8 +25,10 @@ const MAX_STREAM_BUF = 2_000_000
|
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
27
|
* Pre-execution safety checks for bash commands.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Layers: file redirection → destructive commands (rm -rf etc.).
|
|
29
|
+
* Git destructive ops are deliberately NOT rejected — the model would just find a
|
|
30
|
+
* way around the rejection; instead gitGuardSnapshot copies every uncommitted file
|
|
31
|
+
* and the command is ALLOWED (snapshot-then-proceed, never block).
|
|
32
32
|
*/
|
|
33
33
|
function checkBashSafety(command, cwd) {
|
|
34
34
|
if (hasFileRedirection(command)) {
|
|
@@ -40,21 +40,6 @@ function checkBashSafety(command, cwd) {
|
|
|
40
40
|
"(If work was already destroyed, recover from auto-snapshot: checkpoint action=list then action=rewind.)"
|
|
41
41
|
)
|
|
42
42
|
}
|
|
43
|
-
if (shellSegments(command).some(isDestructiveGitSegment)) {
|
|
44
|
-
if (!insideGitRepo(cwd)) {
|
|
45
|
-
throw new Error(`Refusing destructive git command: not a git repository: ${cwd}`)
|
|
46
|
-
}
|
|
47
|
-
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
48
|
-
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
-
}).trim()
|
|
50
|
-
if (status) {
|
|
51
|
-
throw new Error(
|
|
52
|
-
`Refusing destructive git command: uncommitted changes exist.\n` +
|
|
53
|
-
`First create a checkpoint (action=create) to protect current work, then commit or stash before the destructive operation.\n` +
|
|
54
|
-
`(If uncommitted work was already lost, recover from the latest auto-snapshot: checkpoint action=list, then action=rewind.)\n\n${status}`
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
43
|
}
|
|
59
44
|
|
|
60
45
|
/**
|
|
@@ -96,6 +81,43 @@ function killProcessTree(child) {
|
|
|
96
81
|
*
|
|
97
82
|
* Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
|
|
98
83
|
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Detect destructive git commands and auto-snapshot BEFORE execution.
|
|
87
|
+
* The scenario: the model writes uncommitted code, then (after breaking things)
|
|
88
|
+
* runs `git checkout -- .` / `git restore` / `git reset --hard` / `git clean -f` to
|
|
89
|
+
* roll back — which silently DESTROYS all uncommitted work. The pre-task checkpoint
|
|
90
|
+
* cannot help (it was taken before the code was written); only a snapshot taken
|
|
91
|
+
* immediately before the destructive command can.
|
|
92
|
+
*
|
|
93
|
+
* This is layer 1 (defense in depth): a WIDE match that snapshots before the command
|
|
94
|
+
* runs. Layer 2 (checkBashSafety) then rejects the command when uncommitted changes
|
|
95
|
+
* exist — but a rejection alone is not enough: the model may retry a variant that
|
|
96
|
+
* slips through the exact matcher (e.g. `git checkout HEAD -- .`), or run git outside
|
|
97
|
+
* the bash tool. The snapshot taken here survives all of those paths.
|
|
98
|
+
*
|
|
99
|
+
* Matching is intentionally WIDE (false positives are harmless — one extra snapshot;
|
|
100
|
+
* a missed match is a data-loss disaster).
|
|
101
|
+
*/
|
|
102
|
+
const GIT_DESTRUCTIVE_RE = /\bgit\s+(?:checkout\s+(?:[\w./-]+\s+)?--(?!\w)|checkout\s+\.|restore\s+(?!--help\b)(?!--staged\b(?!.*--worktree))|reset\s+--hard|clean\s+-(?=\S*f)(?!\S*n))/i
|
|
103
|
+
|
|
104
|
+
/** Returns { id, notice } when a snapshot was taken, else null. Never throws. */
|
|
105
|
+
async function gitGuardSnapshot(command, cwd) {
|
|
106
|
+
if (!GIT_DESTRUCTIVE_RE.test(command)) return null
|
|
107
|
+
try {
|
|
108
|
+
const { isGitRepo, createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
109
|
+
if (!isGitRepo(cwd)) return null
|
|
110
|
+
const cp = await createCheckpoint(cwd)
|
|
111
|
+
if (!cp) return null
|
|
112
|
+
return {
|
|
113
|
+
id: cp.id,
|
|
114
|
+
notice: `[auto-protection] Destructive git command detected — snapshot ${cp.id} created BEFORE execution (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked). If this command destroyed uncommitted work, restore it: checkpoint action=checkpoint checkpointAction=rewind checkpointId=${cp.id}`,
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
return null // protection is best-effort — never block the command
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
100
122
|
return new Promise((resolve) => {
|
|
101
123
|
const child = spawn(command, {
|
|
@@ -134,13 +156,40 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
|
134
156
|
const timer = setTimeout(killTree, timeout)
|
|
135
157
|
if (signal) signal.addEventListener("abort", killTree, { once: true })
|
|
136
158
|
|
|
137
|
-
|
|
159
|
+
let settled = false
|
|
160
|
+
let graceTimer = null
|
|
161
|
+
const finish = (result) => {
|
|
162
|
+
if (settled) return
|
|
163
|
+
settled = true
|
|
138
164
|
clearTimeout(timer)
|
|
139
|
-
|
|
165
|
+
clearTimeout(graceTimer)
|
|
166
|
+
resolve(result)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
child.on("error", (error) => {
|
|
170
|
+
finish(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Shell exited. Normally 'close' follows within milliseconds — but a BACKGROUND
|
|
174
|
+
// child (start /b, &, nohup …) inherits the stdio pipes, so the pipe stays open
|
|
175
|
+
// and 'close' never fires: the tool would hang until the timeout. Resolve after
|
|
176
|
+
// a short grace period instead, returning whatever output was collected.
|
|
177
|
+
child.on("exit", (code, exitSignal) => {
|
|
178
|
+
graceTimer = setTimeout(() => {
|
|
179
|
+
const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
180
|
+
const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
181
|
+
if (outFlush) onOutput?.(outFlush)
|
|
182
|
+
if (errFlush) onOutput?.(errFlush)
|
|
183
|
+
const status = exitSignal ? `killed: ${exitSignal}` : `exit code ${code}`
|
|
184
|
+
const parts = [`[stdout]:\n${(outBuf + outFlush).trim() || "(empty)"}`]
|
|
185
|
+
if ((errBuf + errFlush).trim()) parts.push(`[stderr]:\n${(errBuf + errFlush).trim()}`)
|
|
186
|
+
parts.push(`(${status})`)
|
|
187
|
+
parts.push("[background] the shell exited but a child process still holds the output pipe — output may be incomplete; the process may still be running")
|
|
188
|
+
finish(truncate(parts.join("\n\n") + truncatedNote))
|
|
189
|
+
}, 1500)
|
|
140
190
|
})
|
|
141
191
|
|
|
142
192
|
child.on("close", (code, exitSignal) => {
|
|
143
|
-
clearTimeout(timer)
|
|
144
193
|
// Flush decoder tails — also push final bytes to panel
|
|
145
194
|
const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
146
195
|
const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
@@ -157,7 +206,7 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
|
157
206
|
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
158
207
|
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
159
208
|
parts.push(`(${status})`)
|
|
160
|
-
|
|
209
|
+
finish(truncate(parts.join("\n\n") + truncatedNote))
|
|
161
210
|
})
|
|
162
211
|
})
|
|
163
212
|
}
|
|
@@ -180,12 +229,18 @@ export const bashTool = {
|
|
|
180
229
|
readonly: false,
|
|
181
230
|
outputPanel: true, // stream stdout/stderr to panel during execution, collapse to summary on completion
|
|
182
231
|
async execute(args, ctx) {
|
|
232
|
+
// Git destructive commands are NEVER rejected — the model would bypass the
|
|
233
|
+
// guard anyway. Instead: snapshot every uncommitted file first, then ALLOW
|
|
234
|
+
// the command. The snapshot makes the rollback reversible (defense in depth:
|
|
235
|
+
// the wide matcher also covers variants like `git checkout HEAD -- .`).
|
|
183
236
|
checkBashSafety(args.command, ctx.cwd)
|
|
184
|
-
|
|
237
|
+
const guard = await gitGuardSnapshot(args.command, ctx.cwd)
|
|
238
|
+
const result = await runBash(args.command, ctx.cwd, {
|
|
185
239
|
timeout: args.timeout ?? BASH_TIMEOUT_MS,
|
|
186
240
|
signal: ctx.signal,
|
|
187
241
|
onOutput: ctx.onOutput,
|
|
188
242
|
})
|
|
243
|
+
return guard ? `${guard.notice}\n\n${result}` : result
|
|
189
244
|
},
|
|
190
245
|
}
|
|
191
246
|
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -32,14 +32,6 @@ export async function runAgentTurn(ctx, text) {
|
|
|
32
32
|
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
33
33
|
pushLine(text, C.text)
|
|
34
34
|
|
|
35
|
-
// Auto-checkpoint before task (git repo only; failure is silent, doesn't block the task)
|
|
36
|
-
try {
|
|
37
|
-
const { createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
38
|
-
await createCheckpoint(agent.cwd)
|
|
39
|
-
} catch {
|
|
40
|
-
// Checkpoint failure doesn't block the task
|
|
41
|
-
}
|
|
42
|
-
|
|
43
35
|
ctx.assistantLabeled = false
|
|
44
36
|
state.processing = true
|
|
45
37
|
state.status = "Processing..."
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -18,6 +18,26 @@ export async function readClipboardText() {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Write text to the system clipboard (Set-Clipboard / pbcopy / xclip). Throws on failure. */
|
|
22
|
+
export async function copyToClipboard(text) {
|
|
23
|
+
const { execFile } = await import("node:child_process")
|
|
24
|
+
const isWin = process.platform === "win32"
|
|
25
|
+
const isMac = process.platform === "darwin"
|
|
26
|
+
await new Promise((resolve, reject) => {
|
|
27
|
+
if (isWin) {
|
|
28
|
+
const child = execFile("powershell", ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
|
|
29
|
+
child.stdin?.end(text)
|
|
30
|
+
} else if (isMac) {
|
|
31
|
+
const child = execFile("pbcopy", [], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
|
|
32
|
+
child.stdin?.end(text)
|
|
33
|
+
} else {
|
|
34
|
+
const child = execFile("xclip", ["-selection", "clipboard"], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
|
|
35
|
+
child.stdin?.end(text)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
21
41
|
/** Insert pasted text into the active text target.
|
|
22
42
|
* Free-text question active → append to its answer (single-line field: newlines stripped).
|
|
23
43
|
* Options question active → ignore (no text field; must not leak into the input box).
|
package/src/tui/index.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { createPickers } from "./pickers.mjs"
|
|
|
28
28
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
29
29
|
import { createInteraction } from "./interaction.mjs"
|
|
30
30
|
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter } from "./clipboard.mjs"
|
|
31
|
+
import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
|
|
31
32
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
32
33
|
import { createKeyHandler } from "./key-handler.mjs"
|
|
33
34
|
import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
@@ -179,6 +180,16 @@ export async function startTUI(agent, opts = {}) {
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// Left-click: \x1b[<0;col;rowM → picker selection / line action menu
|
|
184
|
+
for (const click of parseMouseClicks(text)) {
|
|
185
|
+
try {
|
|
186
|
+
onMouseClick(click.col, click.row)
|
|
187
|
+
} catch (e) {
|
|
188
|
+
pushLine(`[mouse] ${e.message || e}`, C.error)
|
|
189
|
+
render()
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
182
193
|
// Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
|
|
183
194
|
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
184
195
|
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
@@ -375,6 +386,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
375
386
|
}
|
|
376
387
|
})
|
|
377
388
|
|
|
389
|
+
// Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + line action menu.
|
|
390
|
+
const onMouseClick = (col, row) => handleMouseClick({ state, render, showPicker, popPicker, pushLine }, col, row)
|
|
391
|
+
|
|
378
392
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
379
393
|
|
|
380
394
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mouse.mjs — SGR mouse support: click parsing + hit-testing + line actions.
|
|
3
|
+
*
|
|
4
|
+
* Protocol (enabled at startup via \x1b[?1000h\x1b[?1006h):
|
|
5
|
+
* press: \x1b[<b;col;rowM (b=0 left, 64/65 wheel up/down — wheel handled upstream)
|
|
6
|
+
* release: \x1b[<b;col;rowm (ignored — actions fire on press)
|
|
7
|
+
* Coordinates are 1-based; col comes FIRST in the sequence.
|
|
8
|
+
*
|
|
9
|
+
* Only left-click (button 0) is consumed. Everything else stays stripped
|
|
10
|
+
* upstream (sequence fragments must never leak into the input box).
|
|
11
|
+
*/
|
|
12
|
+
import { computeLayout } from "./layout.mjs"
|
|
13
|
+
import { buildConvLines } from "./render-conversation.mjs"
|
|
14
|
+
import { sanitizeDisplay } from "./render.mjs"
|
|
15
|
+
|
|
16
|
+
/** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
|
|
17
|
+
export function parseMouseClicks(text) {
|
|
18
|
+
const out = []
|
|
19
|
+
for (const m of text.matchAll(/\x1b\[<0;(\d+);(\d+)M/g)) {
|
|
20
|
+
out.push({ col: Number(m[1]), row: Number(m[2]) })
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Map a 0-based screen row to a conversation line index (same math as renderConversation). */
|
|
26
|
+
export function convGlobalIndex(convLen, convH, scroll) {
|
|
27
|
+
const maxScroll = Math.max(0, convLen - convH)
|
|
28
|
+
const clamped = Math.min(scroll, maxScroll)
|
|
29
|
+
const end = convLen - clamped
|
|
30
|
+
const start = Math.max(0, end - convH) // content shorter than the panel: rows start at 0
|
|
31
|
+
return (localRow) => {
|
|
32
|
+
if (localRow < 0 || localRow >= convH) return null
|
|
33
|
+
const idx = start + localRow
|
|
34
|
+
return idx >= 0 && idx < convLen ? idx : null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Handle a left-click at SGR (col, row) — 1-based terminal coordinates.
|
|
40
|
+
* ctx: { state, render, showPicker, popPicker, pushLine }
|
|
41
|
+
* Returns true when the click was consumed.
|
|
42
|
+
*/
|
|
43
|
+
export function handleMouseClick(ctx, col, row) {
|
|
44
|
+
const { state, render } = ctx
|
|
45
|
+
const r = row - 1 // 0-based screen row
|
|
46
|
+
if (r < 0) return false
|
|
47
|
+
const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
48
|
+
const layout = computeLayout(state, dims)
|
|
49
|
+
const P = layout.panels
|
|
50
|
+
|
|
51
|
+
// ── Picker: click an option = select it (skip the title row) ──
|
|
52
|
+
if (state.picker && P.picker && r >= P.picker.y && r < P.picker.y + P.picker.h) {
|
|
53
|
+
const p = state.picker
|
|
54
|
+
const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
|
|
55
|
+
const winH = Math.max(1, P.picker.h - 1)
|
|
56
|
+
const start = Math.max(0, Math.min(p.scroll, Math.max(0, p.lines.length - winH)))
|
|
57
|
+
const localRow = r - P.picker.y - 1
|
|
58
|
+
const lineEl = p.lines[start + localRow]
|
|
59
|
+
if (lineEl && lineEl._row !== undefined && items[lineEl._row]) {
|
|
60
|
+
ctx.popPicker(items[lineEl._row])
|
|
61
|
+
}
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Conversation: fold-toggle line expands; a message line opens the action menu ──
|
|
66
|
+
if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
|
|
67
|
+
const convLines = buildConvLines(state, dims.cols)
|
|
68
|
+
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
69
|
+
if (gIdx === null) return false
|
|
70
|
+
const lineEl = convLines[gIdx]
|
|
71
|
+
if (!lineEl) return false
|
|
72
|
+
|
|
73
|
+
// Click on a folded-block hint → expand it
|
|
74
|
+
if (lineEl._foldToggle) {
|
|
75
|
+
state.expandedBlocks ??= new Set()
|
|
76
|
+
state.expandedBlocks.add(lineEl._foldToggle)
|
|
77
|
+
render()
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
// Click on a message line → action menu
|
|
81
|
+
if (lineEl._src !== undefined) {
|
|
82
|
+
const src = state.lines[lineEl._src]
|
|
83
|
+
if (src) {
|
|
84
|
+
openLineMenu(ctx, src)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Line action menu: copy / edit in input / (fold toggle if the source line folds). */
|
|
94
|
+
async function openLineMenu(ctx, srcLine) {
|
|
95
|
+
const { state, render, showPicker, pushLine } = ctx
|
|
96
|
+
const text = sanitizeDisplay(srcLine.text)
|
|
97
|
+
const entries = [
|
|
98
|
+
{ type: "item", text: `📋 Copy line (${text.length} chars)`, action: "copy" },
|
|
99
|
+
{ type: "item", text: "✏️ Edit in input box", action: "edit" },
|
|
100
|
+
]
|
|
101
|
+
const picked = await showPicker("Line actions", entries)
|
|
102
|
+
if (!picked) return
|
|
103
|
+
if (picked.action === "copy") {
|
|
104
|
+
try {
|
|
105
|
+
const { copyToClipboard } = await import("./clipboard.mjs")
|
|
106
|
+
await copyToClipboard(text)
|
|
107
|
+
pushLine(`[clipboard] copied ${text.length} chars`, (await import("./ansi.mjs")).C.dim)
|
|
108
|
+
} catch (e) {
|
|
109
|
+
pushLine(`[clipboard] copy failed: ${e.message}`, (await import("./ansi.mjs")).C.error)
|
|
110
|
+
}
|
|
111
|
+
render()
|
|
112
|
+
} else if (picked.action === "edit") {
|
|
113
|
+
state.input = [...text]
|
|
114
|
+
state.cursor = state.input.length
|
|
115
|
+
render()
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function createPickers(ctx) {
|
|
|
63
63
|
const sel = row === p.index
|
|
64
64
|
if (sel) selLine = lines.length
|
|
65
65
|
const marker = e.marker ? ` ${e.marker}` : ""
|
|
66
|
-
lines.push({ text: `${sel ? " ▸ " : " "}${e.text}${marker}`, color: sel ? ansi.bold + C.text : C.dim })
|
|
66
|
+
lines.push({ text: `${sel ? " ▸ " : " "}${e.text}${marker}`, color: sel ? ansi.bold + C.text : C.dim, _row: row })
|
|
67
67
|
row++
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -10,7 +10,9 @@ let _convCache = { key: "", cols: 0, lines: [] }
|
|
|
10
10
|
|
|
11
11
|
export function convCacheKey(state) {
|
|
12
12
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
13
|
-
|
|
13
|
+
// expandedBlocks participates: expanding/folding a block must invalidate the cache
|
|
14
|
+
const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
|
|
15
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${state.advisorStreaming?.length ?? 0}|${state._advisorThink?.length ?? 0}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
|
|
@@ -50,13 +52,28 @@ function buildConvLines(state, cols) {
|
|
|
50
52
|
text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// Long-message folding: a single line that wraps beyond LONG_FOLD_LINES display rows
|
|
56
|
+
// collapses to [first, "… N more — click/Enter to expand", last]. Keyed by the
|
|
57
|
+
// source-line index (`long-${i}`) so the toggle survives re-renders. This is the
|
|
58
|
+
// folding users actually see — tool outputs, long replies, big error blocks.
|
|
59
|
+
const LONG_FOLD_LINES = 12
|
|
60
|
+
const longKey = `long-${i}`
|
|
61
|
+
const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
|
|
62
|
+
const block = []
|
|
53
63
|
for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
|
|
54
64
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
55
65
|
// Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
|
|
56
66
|
// Runs AFTER wrapping so the ANSI it inserts never skews width math.
|
|
57
|
-
|
|
67
|
+
block.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId, _src: i })
|
|
58
68
|
}
|
|
59
69
|
}
|
|
70
|
+
if (folded && block.length > LONG_FOLD_LINES) {
|
|
71
|
+
convLines.push(block[0])
|
|
72
|
+
convLines.push({ text: ` … ${block.length - 2} more lines — click to expand`, color: C.fold, _foldToggle: longKey, _src: i })
|
|
73
|
+
convLines.push(block[block.length - 1])
|
|
74
|
+
} else {
|
|
75
|
+
convLines.push(...block)
|
|
76
|
+
}
|
|
60
77
|
}
|
|
61
78
|
if (state.reasoning) {
|
|
62
79
|
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
@@ -101,7 +118,7 @@ function buildConvLines(state, cols) {
|
|
|
101
118
|
if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
|
|
102
119
|
folded.push(convLines[i])
|
|
103
120
|
if (blockLen > 2) folded.push(convLines[i + 1])
|
|
104
|
-
folded.push({ text: ` … ${blockLen - 2} more lines —
|
|
121
|
+
folded.push({ text: ` … ${blockLen - 2} more lines — click to expand`, color: C.fold, _foldToggle: foldKey })
|
|
105
122
|
i = j
|
|
106
123
|
continue
|
|
107
124
|
}
|
|
@@ -119,6 +136,8 @@ export function countConvLines(state, cols) {
|
|
|
119
136
|
return buildConvLines(state, cols).length
|
|
120
137
|
}
|
|
121
138
|
|
|
139
|
+
export { buildConvLines }
|
|
140
|
+
|
|
122
141
|
export function renderConversation(state, cols, visibleH, scroll) {
|
|
123
142
|
const convLines = buildConvLines(state, cols)
|
|
124
143
|
const maxScroll = Math.max(0, convLines.length - visibleH)
|