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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +360 -72
  4. package/src/agent/helpers.mjs +7 -3
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +73 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +7 -0
  15. package/src/config.mjs +47 -20
  16. package/src/prompts/advisor-round1.md +23 -0
  17. package/src/prompts/advisor-round2.md +26 -0
  18. package/src/prompts/advisor-round3.md +24 -0
  19. package/src/prompts/coder.md +6 -2
  20. package/src/prompts/discipline.md +23 -6
  21. package/src/prompts/explore.md +2 -0
  22. package/src/prompts/plan.md +2 -0
  23. package/src/prompts/system.md +13 -6
  24. package/src/provider/anthropic.mjs +190 -0
  25. package/src/provider/core.mjs +42 -130
  26. package/src/provider/google.mjs +199 -0
  27. package/src/provider/sse.mjs +112 -0
  28. package/src/proxy.mjs +236 -0
  29. package/src/tools/bash.md +8 -0
  30. package/src/tools/codemode.mjs +5 -16
  31. package/src/tools/edit.md +8 -0
  32. package/src/tools/fetch.md +2 -1
  33. package/src/tools/git.mjs +125 -156
  34. package/src/tools/index.mjs +9 -9
  35. package/src/tools/linter.mjs +46 -32
  36. package/src/tools/read.md +7 -0
  37. package/src/tools/shared.mjs +16 -0
  38. package/src/tools/system.mjs +14 -10
  39. package/src/tools/web.mjs +115 -89
  40. package/src/tools/websearch.md +5 -3
  41. package/src/tui/agent-turn.mjs +86 -75
  42. package/src/tui/cmd-advisor.mjs +138 -49
  43. package/src/tui/cmd-clear.mjs +11 -17
  44. package/src/tui/cmd-config.mjs +226 -142
  45. package/src/tui/cmd-extract.mjs +1 -1
  46. package/src/tui/cmd-fold.mjs +2 -3
  47. package/src/tui/cmd-goal.mjs +58 -27
  48. package/src/tui/cmd-help.mjs +3 -1
  49. package/src/tui/cmd-mcp.mjs +178 -142
  50. package/src/tui/cmd-model.mjs +15 -4
  51. package/src/tui/cmd-new.mjs +7 -13
  52. package/src/tui/cmd-restore.mjs +12 -16
  53. package/src/tui/cmd-session.mjs +28 -32
  54. package/src/tui/cmd-think.mjs +75 -50
  55. package/src/tui/cmd-undo.mjs +19 -23
  56. package/src/tui/cmd-upgrade.mjs +22 -26
  57. package/src/tui/index.mjs +61 -215
  58. package/src/tui/key-handler.mjs +56 -20
  59. package/src/tui/layout.mjs +13 -3
  60. package/src/tui/pickers.mjs +151 -182
  61. package/src/tui/render-conversation.mjs +92 -0
  62. package/src/tui/render-frame.mjs +56 -114
  63. package/src/tui/render-loop.mjs +181 -0
  64. 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 gitDiffTool = {
10
- name: "git_diff",
11
- description: DESC("git_diff"),
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
- staged: { type: "boolean", description: "Show staged changes (default false)" },
16
- path: { type: "string", description: "File or directory to diff (default all)" },
17
- ref: { type: "string", description: "Compare against this ref (default HEAD)" },
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: true,
21
- execute(args, ctx) {
22
- const ref = args.ref ?? "HEAD"
23
- // ref supplied by model and placed before "--": validate charset, prevent "--output=..." etc. from being treated as git options
24
- if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
25
- const flags = args.staged ? ["--staged"] : []
26
- const paths = args.path ? [args.path] : []
27
- const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
28
- return truncate(out || "(no changes)")
29
- },
30
- }
31
-
32
- // ---------------------------------------------------------------- git_status
33
-
34
- export const gitStatusTool = {
35
- name: "git_status",
36
- description: DESC("git_status"),
37
- parameters: {
38
- type: "object",
39
- properties: {},
40
- },
41
- readonly: true,
42
- execute(_args, ctx) {
43
- const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
44
- if (!porcelain) return "(clean no changes)"
45
-
46
- const staged = []
47
- const unstaged = []
48
- const untracked = []
49
- const conflicts = []
50
- for (const line of porcelain.split("\n")) {
51
- if (!line) continue
52
- // porcelain: XY path — 2 status chars + space + file path (some environments have only 1 space)
53
- // Strip possible CR (execFileSync on some Windows git leaves \r at end of line but not in newline)
54
- const clean = line.replace(/\r/g, "")
55
- // Try matching "XY path" or "XY path" (variable spacing)
56
- const m = clean.match(/^(..?)\s+(.+)$/)
57
- if (!m) continue
58
- const [, status, rawFile] = m
59
- // Rename entries in porcelain output are "R old -> new", split for clarity instead of treating as a literal filename
60
- const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
61
- const idx = status[0] ?? " "
62
- const wt = status[1] ?? " "
63
- if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
64
- conflicts.push(file)
65
- } else if (idx === "?" && wt === "?") {
66
- untracked.push(file)
67
- } else {
68
- if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
69
- if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
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)) {
@@ -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, syntaxCheckTool, deleteTool } from "./patch.mjs";
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 { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
8
+ import { gitTool, questionTool } from "./git.mjs";
9
9
  import { checklistTool } from "./checklist.mjs";
10
- import { linterTool } from "./linter.mjs";
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
- syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
16
+ readImageTool, bashTool, globTool, grepTool,
17
17
  websearchTool, lsTool, fetchTool, deleteTool,
18
- gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
19
- checklistTool, linterTool, lspTool, codeModeTool,
18
+ gitTool, questionTool,
19
+ checklistTool, lintTool, lspTool, codeModeTool,
20
20
  ];
21
21
 
22
22
  export {
23
23
  readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
24
- syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
24
+ readImageTool, bashTool, globTool, grepTool,
25
25
  websearchTool, lsTool, fetchTool, deleteTool,
26
- gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
27
- checklistTool, linterTool, lspTool, codeModeTool,
26
+ gitTool, questionTool,
27
+ checklistTool, lintTool, lspTool, codeModeTool,
28
28
  };
@@ -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 linterTool = {
4
- name: "linter",
5
- description: DESC("linter"),
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 path (default: most recently modified 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 { execFileSync } = await import("node:child_process")
15
- const { existsSync } = await import("node:fs")
16
- const { join, relative } = await import("node:path")
17
- const abs = args.path ? resolveInCwd(ctx, args.path) : (ctx.agent?._touchedFiles?.at(-1) || null)
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 `linter: no linter configured for .${ext} files. Supported: ${Object.keys(LANG_CHECKERS).map(e => `.${e}`).join(", ")}`
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 `linter: no linter available for ${args.path || abs}. Install one?`
42
+ return `lint: no linter available for ${abs}. Install one?`
29
43
  },
30
44
  }
31
45
 
32
- // ─── Checker definitions ──────────────────────
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 "linter: ruff not installed. Run: pip install ruff"
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, nodeCheck],
136
- mjs: [eslintCheck, nodeCheck],
137
- cjs: [eslintCheck, nodeCheck],
138
- jsx: [eslintCheck, nodeCheck],
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
@@ -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. */
@@ -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
- onOutput?.(s)
124
- if (outBuf.length < MAX_STREAM_BUF) outBuf += s
125
- else if (!truncatedNote) truncatedNote =
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 (trailing multi-byte sequences that incomplete buffers left pending)
146
- outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
147
- errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
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
- const status = exitSignal
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