thincoder 0.10.0 → 0.11.1
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 +1 -1
- package/package.json +1 -1
- package/src/advisor.mjs +360 -72
- package/src/agent/helpers.mjs +7 -3
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +73 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +47 -20
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +6 -2
- package/src/prompts/discipline.md +23 -6
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +13 -6
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +42 -130
- package/src/provider/google.mjs +199 -0
- package/src/provider/sse.mjs +112 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +125 -156
- package/src/tools/index.mjs +9 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +16 -0
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +115 -89
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +86 -75
- package/src/tui/cmd-advisor.mjs +138 -49
- package/src/tui/cmd-clear.mjs +11 -17
- package/src/tui/cmd-config.mjs +226 -142
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-fold.mjs +2 -3
- package/src/tui/cmd-goal.mjs +58 -27
- package/src/tui/cmd-help.mjs +3 -1
- package/src/tui/cmd-mcp.mjs +178 -142
- package/src/tui/cmd-model.mjs +15 -4
- package/src/tui/cmd-new.mjs +7 -13
- package/src/tui/cmd-restore.mjs +12 -16
- package/src/tui/cmd-session.mjs +28 -32
- package/src/tui/cmd-think.mjs +75 -50
- package/src/tui/cmd-undo.mjs +19 -23
- package/src/tui/cmd-upgrade.mjs +22 -26
- package/src/tui/index.mjs +61 -215
- package/src/tui/key-handler.mjs +56 -20
- package/src/tui/layout.mjs +13 -3
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +56 -114
- package/src/tui/render-loop.mjs +181 -0
- package/src/tui/slash-commands.mjs +26 -16
package/src/tools/git.mjs
CHANGED
|
@@ -3,104 +3,138 @@ import {
|
|
|
3
3
|
truncate,
|
|
4
4
|
runGit
|
|
5
5
|
} from "./shared.mjs";
|
|
6
|
+
import { escapeXml } from "../agent/helpers.mjs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
|
|
9
|
-
export const
|
|
10
|
-
name: "
|
|
11
|
-
description:
|
|
10
|
+
export const gitTool = {
|
|
11
|
+
name: "git",
|
|
12
|
+
description:
|
|
13
|
+
"Run a git command. Use this to see uncommitted changes, staged changes, diff against a ref, recent commits, or manage checkpoints. Only works inside a git repository.\n" +
|
|
14
|
+
"- action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.\n" +
|
|
15
|
+
"- action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.\n" +
|
|
16
|
+
"- action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.\n" +
|
|
17
|
+
"- action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).",
|
|
12
18
|
parameters: {
|
|
13
19
|
type: "object",
|
|
14
20
|
properties: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
|
|
22
|
+
// diff/log params
|
|
23
|
+
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
24
|
+
path: { type: "string", description: "(diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to" },
|
|
25
|
+
ref: { type: "string", description: "(diff) Compare against this ref (default HEAD)" },
|
|
26
|
+
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
27
|
+
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
28
|
+
// checkpoint params
|
|
29
|
+
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot" },
|
|
30
|
+
checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
|
|
18
31
|
},
|
|
32
|
+
required: ["action"],
|
|
19
33
|
},
|
|
20
|
-
readonly:
|
|
21
|
-
execute(args, ctx) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
34
|
+
readonly: false,
|
|
35
|
+
async execute(args, ctx) {
|
|
36
|
+
switch (args.action) {
|
|
37
|
+
case "diff": {
|
|
38
|
+
const ref = args.ref ?? "HEAD"
|
|
39
|
+
if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
40
|
+
const flags = args.staged ? ["--staged"] : []
|
|
41
|
+
const paths = args.path ? [args.path] : []
|
|
42
|
+
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
43
|
+
return truncate(out || "(no changes)")
|
|
44
|
+
}
|
|
45
|
+
case "status": {
|
|
46
|
+
const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
|
|
47
|
+
if (!porcelain) return "(clean — no changes)"
|
|
48
|
+
|
|
49
|
+
const staged = []
|
|
50
|
+
const unstaged = []
|
|
51
|
+
const untracked = []
|
|
52
|
+
const conflicts = []
|
|
53
|
+
for (const line of porcelain.split("\n")) {
|
|
54
|
+
if (!line) continue
|
|
55
|
+
const clean = line.replace(/\r/g, "")
|
|
56
|
+
const m = clean.match(/^(..?)\s+(.+)$/)
|
|
57
|
+
if (!m) continue
|
|
58
|
+
const [, status, rawFile] = m
|
|
59
|
+
const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
|
|
60
|
+
const idx = status[0] ?? " "
|
|
61
|
+
const wt = status[1] ?? " "
|
|
62
|
+
if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
|
|
63
|
+
conflicts.push(file)
|
|
64
|
+
} else if (idx === "?" && wt === "?") {
|
|
65
|
+
untracked.push(file)
|
|
66
|
+
} else {
|
|
67
|
+
if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
|
|
68
|
+
if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const parts = []
|
|
72
|
+
if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
|
|
73
|
+
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
74
|
+
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
75
|
+
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
76
|
+
return truncate(parts.join("\n\n"))
|
|
77
|
+
}
|
|
78
|
+
case "log": {
|
|
79
|
+
const n = Math.min(Math.max(1, args.count ?? 10), 200)
|
|
80
|
+
const isOneline = args.oneline
|
|
81
|
+
const cmdArgs = isOneline
|
|
82
|
+
? ["log", "-" + n, "--oneline"]
|
|
83
|
+
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
84
|
+
if (args.path) cmdArgs.push("--", args.path)
|
|
85
|
+
const out = runGit(ctx.cwd, cmdArgs)
|
|
86
|
+
return truncate(out || "(no commits)")
|
|
70
87
|
}
|
|
88
|
+
case "checkpoint": {
|
|
89
|
+
const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
90
|
+
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
91
|
+
|
|
92
|
+
const sub = args.checkpointAction
|
|
93
|
+
if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat"
|
|
94
|
+
|
|
95
|
+
if (sub === "create") {
|
|
96
|
+
const cp = await createCheckpoint(ctx.cwd)
|
|
97
|
+
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
98
|
+
}
|
|
99
|
+
if (sub === "rewind") {
|
|
100
|
+
if (!args.checkpointId) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
|
|
101
|
+
const s = await rewind(ctx.cwd, args.checkpointId, { path: args.path })
|
|
102
|
+
if (args.path) {
|
|
103
|
+
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.)`
|
|
104
|
+
}
|
|
105
|
+
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.)`
|
|
106
|
+
}
|
|
107
|
+
if (sub === "cat") {
|
|
108
|
+
if (!args.checkpointId) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
|
|
109
|
+
if (!args.path) throw new Error("path is required for cat — specify which file to read")
|
|
110
|
+
const { catFile } = await import("../git/checkpoint.mjs")
|
|
111
|
+
return await catFile(ctx.cwd, args.checkpointId, args.path)
|
|
112
|
+
}
|
|
113
|
+
if (sub === "list") {
|
|
114
|
+
const cps = await listCheckpoints(ctx.cwd)
|
|
115
|
+
if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
|
|
116
|
+
|
|
117
|
+
// Specific id: show the file tree within that snapshot
|
|
118
|
+
if (args.checkpointId) {
|
|
119
|
+
const cp = cps.find((c) => c.id === args.checkpointId)
|
|
120
|
+
if (!cp) throw new Error(`checkpoint ${args.checkpointId} not found`)
|
|
121
|
+
return formatFileTree(cp)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Overview: list of all snapshots (file names are XML-escaped: they are
|
|
125
|
+
// untrusted input that flows back into the model's context)
|
|
126
|
+
return cps.map((c) => {
|
|
127
|
+
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
128
|
+
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
|
|
129
|
+
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
|
|
130
|
+
return parts.join(" ")
|
|
131
|
+
}).join("\n")
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat`)
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
|
|
71
137
|
}
|
|
72
|
-
const parts = []
|
|
73
|
-
if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
|
|
74
|
-
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
75
|
-
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
76
|
-
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
77
|
-
return truncate(parts.join("\n\n"))
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------- git_log
|
|
82
|
-
|
|
83
|
-
export const gitLogTool = {
|
|
84
|
-
name: "git_log",
|
|
85
|
-
description: DESC("git_log"),
|
|
86
|
-
parameters: {
|
|
87
|
-
type: "object",
|
|
88
|
-
properties: {
|
|
89
|
-
count: { type: "number", description: "Number of commits (default 10)" },
|
|
90
|
-
path: { type: "string", description: "File or directory (default all)" },
|
|
91
|
-
oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
readonly: true,
|
|
95
|
-
execute(args, ctx) {
|
|
96
|
-
const n = Math.min(Math.max(1, args.count ?? 10), 200)
|
|
97
|
-
const isOneline = args.oneline
|
|
98
|
-
const cmdArgs = isOneline
|
|
99
|
-
? ["log", "-" + n, "--oneline"]
|
|
100
|
-
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
101
|
-
if (args.path) cmdArgs.push("--", args.path)
|
|
102
|
-
const out = runGit(ctx.cwd, cmdArgs)
|
|
103
|
-
return truncate(out || "(no commits)")
|
|
104
138
|
},
|
|
105
139
|
}
|
|
106
140
|
|
|
@@ -128,79 +162,17 @@ export const questionTool = {
|
|
|
128
162
|
},
|
|
129
163
|
}
|
|
130
164
|
|
|
131
|
-
/** Execute a git command; returns empty string when not a git repo / git unavailable */
|
|
132
|
-
|
|
133
|
-
// ---------------------------------------------------------------- checkpoint
|
|
134
|
-
|
|
135
|
-
export const checkpointTool = {
|
|
136
|
-
name: "checkpoint",
|
|
137
|
-
description: DESC("checkpoint"),
|
|
138
|
-
parameters: {
|
|
139
|
-
type: "object",
|
|
140
|
-
properties: {
|
|
141
|
-
action: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "list snapshots / create one now / restore a snapshot by id / read a file's content from a snapshot" },
|
|
142
|
-
id: { type: "string", description: "Snapshot id (required for rewind and cat; optional for list — shows file tree of that snapshot)" },
|
|
143
|
-
path: { type: "string", description: "Restore only this single file from the checkpoint (tracked or untracked). Other files are left untouched." },
|
|
144
|
-
},
|
|
145
|
-
required: ["action"],
|
|
146
|
-
},
|
|
147
|
-
readonly: false,
|
|
148
|
-
async execute(args, ctx) {
|
|
149
|
-
const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
150
|
-
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
151
|
-
if (args.action === "create") {
|
|
152
|
-
const cp = await createCheckpoint(ctx.cwd)
|
|
153
|
-
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
154
|
-
}
|
|
155
|
-
if (args.action === "rewind") {
|
|
156
|
-
if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
|
|
157
|
-
const s = await rewind(ctx.cwd, args.id, { path: args.path })
|
|
158
|
-
if (args.path) {
|
|
159
|
-
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.id}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
160
|
-
}
|
|
161
|
-
return `Rewound to checkpoint ${args.id}: 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.)`
|
|
162
|
-
}
|
|
163
|
-
if (args.action === "cat") {
|
|
164
|
-
if (!args.id) throw new Error("id is required for cat — use action=list to see snapshot ids")
|
|
165
|
-
if (!args.path) throw new Error("path is required for cat — specify which file to read")
|
|
166
|
-
const { catFile } = await import("../git/checkpoint.mjs")
|
|
167
|
-
return await catFile(ctx.cwd, args.id, args.path)
|
|
168
|
-
}
|
|
169
|
-
if (args.action === "list") {
|
|
170
|
-
const cps = await listCheckpoints(ctx.cwd)
|
|
171
|
-
if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
|
|
172
|
-
|
|
173
|
-
// Specific id: show the file tree within that snapshot
|
|
174
|
-
if (args.id) {
|
|
175
|
-
const cp = cps.find((c) => c.id === args.id)
|
|
176
|
-
if (!cp) throw new Error(`checkpoint ${args.id} not found`)
|
|
177
|
-
return formatFileTree(cp)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Overview: list of all snapshots
|
|
181
|
-
return cps.map((c) => {
|
|
182
|
-
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
183
|
-
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
|
|
184
|
-
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
|
|
185
|
-
return parts.join(" ")
|
|
186
|
-
}).join("\n")
|
|
187
|
-
}
|
|
188
|
-
throw new Error(`Unknown action: ${args.action}`)
|
|
189
|
-
},
|
|
190
|
-
}
|
|
191
|
-
|
|
192
165
|
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
193
166
|
function formatFileTree(cp) {
|
|
167
|
+
// File names are XML-escaped: untrusted input that flows back into the model's context
|
|
194
168
|
const all = [
|
|
195
|
-
...(cp.tracked ?? []).map((f) => ({ path: f, type: "" })),
|
|
196
|
-
...(cp.untracked ?? []).map((f) => ({ path: f, type: " (untracked)" })),
|
|
169
|
+
...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
|
|
170
|
+
...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
|
|
197
171
|
]
|
|
198
172
|
if (all.length === 0) return "(empty checkpoint)"
|
|
199
173
|
|
|
200
|
-
// Sort by path (directories group naturally)
|
|
201
174
|
all.sort((a, b) => a.path.localeCompare(b.path))
|
|
202
175
|
|
|
203
|
-
// Build directory → file list map
|
|
204
176
|
const tree = new Map()
|
|
205
177
|
for (const { path, type } of all) {
|
|
206
178
|
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
|
|
@@ -208,12 +180,10 @@ function formatFileTree(cp) {
|
|
|
208
180
|
tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
|
|
209
181
|
}
|
|
210
182
|
|
|
211
|
-
// Output sorted by directory
|
|
212
183
|
const lines = []
|
|
213
184
|
const dirs = [...tree.keys()].sort()
|
|
214
185
|
for (const dir of dirs) {
|
|
215
186
|
if (dir !== "." && !lines.includes(dir + "/")) {
|
|
216
|
-
// Parent directory before child directory
|
|
217
187
|
const parts = dir.split("/")
|
|
218
188
|
for (let i = 1; i <= parts.length; i++) {
|
|
219
189
|
const prefix = parts.slice(0, i).join("/") + "/"
|
|
@@ -221,7 +191,6 @@ function formatFileTree(cp) {
|
|
|
221
191
|
}
|
|
222
192
|
}
|
|
223
193
|
}
|
|
224
|
-
// Ensure directories precede files
|
|
225
194
|
for (const dir of dirs) {
|
|
226
195
|
if (dir !== ".") {
|
|
227
196
|
for (const { name, type } of tree.get(dir)) {
|
package/src/tools/index.mjs
CHANGED
|
@@ -2,27 +2,27 @@
|
|
|
2
2
|
export { toOpenAISchema } from "./shared.mjs";
|
|
3
3
|
|
|
4
4
|
import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
|
|
5
|
-
import { applyPatchTool,
|
|
5
|
+
import { applyPatchTool, deleteTool } from "./patch.mjs";
|
|
6
6
|
import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
|
|
7
7
|
import { websearchTool, fetchTool } from "./web.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { gitTool, questionTool } from "./git.mjs";
|
|
9
9
|
import { checklistTool } from "./checklist.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { lintTool } from "./linter.mjs";
|
|
11
11
|
import { lspTool } from "./lsp.mjs";
|
|
12
12
|
import { codeModeTool } from "./codemode.mjs";
|
|
13
13
|
|
|
14
14
|
export const builtinTools = [
|
|
15
15
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
16
|
-
|
|
16
|
+
readImageTool, bashTool, globTool, grepTool,
|
|
17
17
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
18
|
-
|
|
19
|
-
checklistTool,
|
|
18
|
+
gitTool, questionTool,
|
|
19
|
+
checklistTool, lintTool, lspTool, codeModeTool,
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
export {
|
|
23
23
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
24
|
-
|
|
24
|
+
readImageTool, bashTool, globTool, grepTool,
|
|
25
25
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
26
|
-
|
|
27
|
-
checklistTool,
|
|
26
|
+
gitTool, questionTool,
|
|
27
|
+
checklistTool, lintTool, lspTool, codeModeTool,
|
|
28
28
|
};
|
package/src/tools/linter.mjs
CHANGED
|
@@ -1,38 +1,66 @@
|
|
|
1
1
|
import { DESC, resolveInCwd } from "./shared.mjs"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { join, relative } from "node:path"
|
|
2
5
|
|
|
3
|
-
export const
|
|
4
|
-
name: "
|
|
5
|
-
description:
|
|
6
|
+
export const lintTool = {
|
|
7
|
+
name: "lint",
|
|
8
|
+
description:
|
|
9
|
+
"Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.\n" +
|
|
10
|
+
"Without 'full', runs a fast node --check (JS/TS syntax only, catches parse errors in milliseconds).\n" +
|
|
11
|
+
"With 'full', runs the language-aware cascade: eslint → tsc –noEmit → node --check (JS/TS/TSX); ruff (Python); cargo check (Rust); go vet (Go).\n" +
|
|
12
|
+
"Use the fast default after every write/edit; use 'full' before declaring a task complete.",
|
|
6
13
|
parameters: {
|
|
7
14
|
type: "object",
|
|
8
15
|
properties: {
|
|
9
|
-
path: { type: "string", description: "File
|
|
16
|
+
path: { type: "string", description: "File to check (default: most recently modified file)" },
|
|
17
|
+
full: { type: "boolean", description: "Run the full language-aware cascade instead of just node --check (default false)" },
|
|
10
18
|
},
|
|
19
|
+
required: [],
|
|
11
20
|
},
|
|
12
21
|
readonly: true,
|
|
13
22
|
async execute(args, ctx) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!abs) return "linter: no file specified and no recently modified file to check"
|
|
23
|
+
const abs = args.path
|
|
24
|
+
? resolveInCwd(ctx, args.path)
|
|
25
|
+
: (ctx.agent?._touchedFiles?.at(-1) || null)
|
|
26
|
+
if (!abs) return "lint: no file specified and no recently modified file to check"
|
|
19
27
|
|
|
28
|
+
if (!args.full) {
|
|
29
|
+
// Fast path: node --check only
|
|
30
|
+
return nodeCheckResult(abs)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Full cascade: language-aware (eslint → tsc → node --check, etc.)
|
|
20
34
|
const ext = abs.split(".").pop()?.toLowerCase()
|
|
21
35
|
const checkers = LANG_CHECKERS[ext]
|
|
22
|
-
if (!checkers) return
|
|
36
|
+
if (!checkers) return nodeCheckResult(abs) // fall back to node --check
|
|
23
37
|
|
|
24
38
|
for (const checker of checkers) {
|
|
25
39
|
const result = await checker(abs, { cwd: ctx.cwd, existsSync, execFileSync, join, relative })
|
|
26
40
|
if (result !== null) return result
|
|
27
41
|
}
|
|
28
|
-
return `
|
|
42
|
+
return `lint: no linter available for ${abs}. Install one?`
|
|
29
43
|
},
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
|
|
46
|
+
function nodeCheckResult(abs) {
|
|
47
|
+
if (!/\.(?:m?js|cjs|m?ts|cts|jsx|tsx)$/.test(abs)) {
|
|
48
|
+
return `lint (check): only JS/TS-family files supported for fast syntax check; use full=true for other languages. Path: ${abs}`
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
execFileSync(process.execPath, ["--check", abs], {
|
|
52
|
+
encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
})
|
|
54
|
+
return `Syntax OK: ${abs}`
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const msg = (e.stderr || e.stdout || e.message || "").trim()
|
|
57
|
+
return `Syntax error in ${abs}:\n${msg || "(unknown)"}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Full-check cascade checkers ──────────────────────
|
|
33
62
|
|
|
34
63
|
async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
|
|
35
|
-
// Walk up to find eslint config
|
|
36
64
|
let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
|
|
37
65
|
while (true) {
|
|
38
66
|
for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
|
|
@@ -73,18 +101,6 @@ async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
|
|
|
73
101
|
}
|
|
74
102
|
}
|
|
75
103
|
|
|
76
|
-
async function nodeCheck(file, { execFileSync, cwd }) {
|
|
77
|
-
if (!/\.(m?js|cjs)$/.test(file)) return null
|
|
78
|
-
try {
|
|
79
|
-
execFileSync(process.execPath, ["--check", file], {
|
|
80
|
-
cwd, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
-
})
|
|
82
|
-
return "✓ node --check: Syntax OK"
|
|
83
|
-
} catch (e) {
|
|
84
|
-
return `✗ node --check: ${(e.stderr || e.message).slice(0, 500)}`
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
104
|
async function ruffCheck(file, { cwd, execFileSync }) {
|
|
89
105
|
if (!/\.py$/.test(file)) return null
|
|
90
106
|
try {
|
|
@@ -93,7 +109,7 @@ async function ruffCheck(file, { cwd, execFileSync }) {
|
|
|
93
109
|
})
|
|
94
110
|
return "✓ ruff: no issues"
|
|
95
111
|
} catch (e) {
|
|
96
|
-
if (e.code === "ENOENT") return "
|
|
112
|
+
if (e.code === "ENOENT") return "lint: ruff not installed. Run: pip install ruff"
|
|
97
113
|
const stdout = (e.stdout || "").trim()
|
|
98
114
|
if (stdout) return stdout
|
|
99
115
|
return `✗ ruff: ${(e.stderr || e.message).slice(0, 500)}`
|
|
@@ -129,13 +145,11 @@ async function goVet(file, { cwd, execFileSync }) {
|
|
|
129
145
|
}
|
|
130
146
|
}
|
|
131
147
|
|
|
132
|
-
// ─── Language → checkers (first available wins) ──
|
|
133
|
-
|
|
134
148
|
const LANG_CHECKERS = {
|
|
135
|
-
js: [eslintCheck
|
|
136
|
-
mjs: [eslintCheck
|
|
137
|
-
cjs: [eslintCheck
|
|
138
|
-
jsx: [eslintCheck
|
|
149
|
+
js: [eslintCheck],
|
|
150
|
+
mjs: [eslintCheck],
|
|
151
|
+
cjs: [eslintCheck],
|
|
152
|
+
jsx: [eslintCheck],
|
|
139
153
|
ts: [eslintCheck, tscCheck],
|
|
140
154
|
tsx: [eslintCheck, tscCheck],
|
|
141
155
|
mts: [eslintCheck, tscCheck],
|
package/src/tools/read.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
Read a text file. Returns numbered lines. Use offset/limit to page large files.
|
|
2
|
+
|
|
3
|
+
**Routing:**
|
|
4
|
+
- Don't know which file? → `repo_outline` / `code_search` / `glob` first
|
|
5
|
+
- Know the symbol but not the location? → `code_search` or `lsp definition`
|
|
6
|
+
- Know the file but not the lines? → `grep` to find line numbers, then read that range with offset/limit
|
|
7
|
+
- Reading an image? → `read_image` instead
|
|
8
|
+
|
|
2
9
|
Parameters:
|
|
3
10
|
- path (required): File path, relative to cwd or absolute (alias: filePath)
|
|
4
11
|
- offset: 1-based line number to start reading from
|
package/src/tools/shared.mjs
CHANGED
|
@@ -20,6 +20,22 @@ export const BASH_TIMEOUT_MS = 120_000
|
|
|
20
20
|
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
21
21
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
22
22
|
|
|
23
|
+
/** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs. */
|
|
24
|
+
export function isPrivateHost(hostname) {
|
|
25
|
+
const h = hostname.toLowerCase()
|
|
26
|
+
if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return false
|
|
27
|
+
if (h === "127.0.0.1" || h.startsWith("127.")) return false
|
|
28
|
+
if (h === "169.254.169.254" || h === "metadata.google.internal") return true
|
|
29
|
+
// IPv6 private ranges — only check if host contains ":"
|
|
30
|
+
if (h.includes(":") && (h === "::1" || h === "fe80::1" || h.startsWith("fc") || h.startsWith("fd"))) return true
|
|
31
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
32
|
+
if (m) {
|
|
33
|
+
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
34
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || a === 192 && b === 168 || a === 169 && b === 254 || a === 0) return true
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/** Normalize Windows line endings to Unix: \r\n → \n.
|
|
24
40
|
* Applied on every text-file read so that edit/hash matching
|
|
25
41
|
* and hash computation are platform-consistent. */
|
package/src/tools/system.mjs
CHANGED
|
@@ -119,16 +119,15 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
|
119
119
|
|
|
120
120
|
child.stdout.on("data", (d) => {
|
|
121
121
|
const s = sanitizeOutput(outDecoder(d))
|
|
122
|
-
if (s)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
"\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
|
|
127
|
-
}
|
|
122
|
+
if (s) onOutput?.(s)
|
|
123
|
+
if (outBuf.length < MAX_STREAM_BUF) outBuf += s
|
|
124
|
+
else if (!truncatedNote) truncatedNote =
|
|
125
|
+
"\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
|
|
128
126
|
})
|
|
129
127
|
|
|
130
128
|
child.stderr.on("data", (d) => {
|
|
131
129
|
const s = sanitizeOutput(errDecoder(d))
|
|
130
|
+
if (s) onOutput?.(s)
|
|
132
131
|
if (errBuf.length < MAX_STREAM_BUF) errBuf += s
|
|
133
132
|
})
|
|
134
133
|
|
|
@@ -142,11 +141,16 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
|
142
141
|
|
|
143
142
|
child.on("close", (code, exitSignal) => {
|
|
144
143
|
clearTimeout(timer)
|
|
145
|
-
// Flush decoder tails
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// Flush decoder tails — also push final bytes to panel
|
|
145
|
+
const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
146
|
+
const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
147
|
+
outBuf += outFlush
|
|
148
|
+
errBuf += errFlush
|
|
149
|
+
if (outFlush) onOutput?.(outFlush)
|
|
150
|
+
if (errFlush) onOutput?.(errFlush)
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
// Windows has no POSIX signals — check signal.aborted for user interrupts
|
|
153
|
+
const status = (exitSignal || signal?.aborted)
|
|
150
154
|
? `killed: ${signal?.aborted ? "user interrupted" : "timeout"}`
|
|
151
155
|
: `exit code ${code}`
|
|
152
156
|
|