thincoder 0.12.59 → 0.12.61

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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -16,7 +16,7 @@ import { relative, dirname } from "node:path";
16
16
  * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
17
17
  * Consume hunk lines by the line counts in the @@ header — LLMs often strip context blank lines to pure empty lines,
18
18
  * so we use counts rather than first characters to determine hunk boundaries.
19
- * D15.6: a bare "@@" header (no coordinates) is accepted — the hunk body runs until the next hunk/file header
19
+ * APPLY-PATCH.md §3: a bare "@@" header (no coordinates) is accepted — the hunk body runs until the next hunk/file header
20
20
  * ("@@" / "--- " / "+++ " / "diff " / "index "), purely located by its ops.
21
21
  */
22
22
  /**
@@ -68,7 +68,7 @@ function parsePatch(patch) {
68
68
  if (!cur) throw new Error("Malformed patch: hunk header before any file header")
69
69
  const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
70
70
  if (!m) {
71
- // D15.6 (TOOLS.md §15):坐标裸 "@@" 头——hunk 完全靠操作行定位。
71
+ // APPLY-PATCH.md §3:坐标裸 "@@" 头——hunk 完全靠操作行定位。
72
72
  // 无行数可用:操作行以 " " / "-" / "+" 开头(空行宽容为上下文行),
73
73
  // 直到下一个 hunk 头 / 文件头 "@@"/"--- "/"+++ "/"diff "/"index " 为止。
74
74
  if (!/^@@(?: @@)?\s*$/.test(line)) {
@@ -94,7 +94,7 @@ function parsePatch(patch) {
94
94
  }
95
95
  if (hunk.ops.length === 0) throw new Error(`Malformed patch: empty coordinate-less hunk "${line.trim()}"`)
96
96
  const ctxCount = hunk.ops.filter((o) => o.type === " ").length
97
- // §15.3 (TOOLS.md D15.10.1——2026-09-04):context<2 且含 ≥1 个 - 行 → 接受——定位锚 = hunk 内
97
+ // §3 (APPLY-PATCH.md——2026-09-04):context<2 且含 ≥1 个 - 行 → 接受——定位锚 = hunk 内
98
98
  // 匹配行序列(空格上下文行 + - 行——按出现序)连续——唯一匹配即应用(applyHunks 既有锚匹配域——
99
99
  // 多匹配 / not-found 语义不变)。0 上下文与 1 上下文同待遇(评审 #4a)。
100
100
  // 纯 +(无 - 锚)且 context<2 仍拒——插入位置不可判——报错引导加锚(NF15.8c)。
@@ -8,5 +8,9 @@ Notes:
8
8
  - The agent loop pauses until the user answers
9
9
  - The answer is injected as the next user message
10
10
  - Returns the user's answer — the chosen option or free text — as the next message; the loop resumes when it arrives.
11
+ - Availability: this tool needs an interactive UI — in contexts without one (headless runs, subagent children) it returns an error instead of asking; put the question in your reply text instead.
11
12
  - Use sparingly — prefer making reasonable decisions when possible
13
+ - Ask ONE question per call — never bundle multiple sub-questions into one question string; ask the next one after the answer arrives.
14
+ - Keep the question text short — one or two sentences. Background, context, and analysis belong in your normal reply text, NOT in the question.
15
+ - Routine confirmations (confirm gates) belong in your plain reply text — the user answers in their next message. Use this tool ONLY when you need the user's decision or input to proceed.
12
16
  - After receiving an answer about a design convention, tool preference, or recurring pattern: save it with the memory tool (action: put). This prevents asking the same question in future sessions — the user shouldn't have to repeat their preferences.
@@ -0,0 +1,26 @@
1
+ import { DESC } from "./shared.mjs";
2
+
3
+ export const questionTool = {
4
+ name: "question",
5
+ description: DESC("question"),
6
+ parameters: {
7
+ type: "object",
8
+ properties: {
9
+ question: { type: "string", description: "The question to ask the user" },
10
+ options: {
11
+ type: "array",
12
+ items: { type: "string" },
13
+ description: "Single-choice options for the user to pick from (optional)",
14
+ },
15
+ },
16
+ required: ["question"],
17
+ },
18
+ readonly: true,
19
+ async execute(args, ctx) {
20
+ if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
21
+ // §22 D-Q3 机械限制(2026-09-06 用户裁定:100 字符 / 4 条——超限拒绝回模型,不弹卡、不调 onQuestion)
22
+ if (args.question.length > 100) return "(error: question too long (>100 chars) — ask ONE short question; background belongs in your normal reply text)"
23
+ if (Array.isArray(args.options) && args.options.length > 4) return "(error: too many options (>4) — offer at most 4 plain-string options)"
24
+ return ctx.onQuestion(args.question, args.options ?? [])
25
+ },
26
+ }
package/src/tools/read.md CHANGED
@@ -7,12 +7,11 @@ Read a text file. Returns numbered lines. Use offset/limit to page large files.
7
7
  - Know the symbol but not the location? → `code_search` or `lsp definition`
8
8
  - Know the file but not the lines? → `grep` to find line numbers, then read that range with offset/limit
9
9
  - Reading an image? → `read_image` instead
10
- - Reading a PDF? → `read_pdf` instead (read would decode the binary as UTF-8 garbage)
11
10
 
12
11
  Parameters:
13
12
  - path (required): File path, relative to cwd or absolute (alias: filePath)
14
13
  - offset: 1-based line number to start reading from
15
- - limit: Max lines to return (default 2000)
14
+ - limit: Max lines to return (default 2000) — windows over files beyond that return head + `…(truncated: K lines in middle, use offset to continue)` + the file's real tail lines, so the file end is never hidden
16
15
  - hashes: Include SHA256 content hashes per line (for hashline_edit). Set true before using hashline_edit.
17
16
 
18
17
  Notes:
@@ -5,4 +5,4 @@ Parameters:
5
5
 
6
6
  Notes:
7
7
  - Raster formats only (png/jpg/gif/webp): no mainstream vision API (Kimi, Anthropic, OpenAI, Gemini) accepts svg or bmp, and an unsupported image part in history makes every subsequent request fail with 400. svg files are returned as text source instead (readable by any model); bmp is rejected — convert to PNG first.
8
- - This tool only works with models that support vision/image input (Kimi K3, Qwen3.8, MiniMax M3, GLM-5.3-Flash). Pure text models (DeepSeek V4, GLM-5) will receive an error — except svg, which needs no vision support since it is read as text.
8
+ - This tool only works with models that support vision/image input (capability is spec-driven the model spec decides, not a hardcoded list). Models without vision support will receive an error — except svg, which needs no vision support since it is read as text.
@@ -0,0 +1,236 @@
1
+ import {
2
+ DESC,
3
+ truncate,
4
+ resolveInCwd,
5
+ globToRegex,
6
+ splitGlobPatterns,
7
+ compileGlobMatchers,
8
+ normalizeEOL,
9
+ IGNORED_DIRS,
10
+ } from "./shared.mjs";
11
+ import { readFile, readdir, stat, lstat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+
14
+ /** Escape a string for literal regex matching (grep literal=true). */
15
+ function escapeRegExp(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
17
+ }
18
+
19
+ // ---------------------------------------------------------------- glob
20
+
21
+ export const globTool = {
22
+ name: "glob",
23
+ description: DESC("glob"),
24
+ parameters: {
25
+ type: "object",
26
+ properties: {
27
+ pattern: { type: "string", description: "Glob pattern — supports **, *, ?, [..], {a,b} braces, and space-separated exclusion (\"**/*.js !test/**\")" },
28
+ path: { type: "string", description: "Directory to search in (default cwd)" },
29
+ },
30
+ required: ["pattern"],
31
+ },
32
+ readonly: true,
33
+ async execute(args, ctx) {
34
+ const base = resolveInCwd(ctx, args.path ?? ".")
35
+ // §17 调用侧拆分(评审 #5 职责分层):空格分隔 include !exclude 多模式在这里拆;
36
+ // globToRegex 只收单个模式(数组由 compileGlobMatchers 收)。
37
+ let match
38
+ try {
39
+ const parts = splitGlobPatterns(args.pattern)
40
+ if (parts.length === 0) return "Error: pattern is required"
41
+ match = compileGlobMatchers(parts).test
42
+ } catch (e) {
43
+ return `glob error: invalid pattern "${args.pattern}": ${e.message}`
44
+ }
45
+ const results = []
46
+ for await (const relPath of walkFiles(base)) {
47
+ if (match(relPath)) {
48
+ results.push(relPath)
49
+ if (results.length >= 1000) break
50
+ }
51
+ }
52
+ if (results.length === 0) return "(no matches)"
53
+ return truncate(results.sort().join("\n"))
54
+ },
55
+ }
56
+
57
+ /** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
58
+ async function* walkFiles(dir, rel = "") {
59
+ let entries
60
+ try {
61
+ entries = await readdir(dir, { withFileTypes: true })
62
+ } catch {
63
+ return
64
+ }
65
+ for (const e of entries) {
66
+ // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
67
+ if (e.isSymbolicLink()) continue
68
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
69
+ const relPath = rel ? `${rel}/${e.name}` : e.name
70
+ if (e.isDirectory()) {
71
+ yield* walkFiles(join(dir, e.name), relPath)
72
+ } else {
73
+ yield relPath
74
+ }
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------- grep
79
+
80
+ export const grepTool = {
81
+ name: "grep",
82
+ description: DESC("grep"),
83
+ parameters: {
84
+ type: "object",
85
+ properties: {
86
+ pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
87
+ path: { type: "string", description: "Directory or file to search (default cwd)" },
88
+ glob: { type: "string", description: "Only search files matching this glob — supports **, *, ?, [..], {a,b} braces and space-separated exclusion (\"**/*.js !test/**\")" },
89
+ ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
90
+ literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
91
+ before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
92
+ after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
93
+ },
94
+ required: ["pattern"],
95
+ },
96
+ readonly: true,
97
+ async execute(args, ctx) {
98
+ const base = resolveInCwd(ctx, args.path ?? ".")
99
+ let regex
100
+ try {
101
+ const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
102
+ regex = new RegExp(pat, args.ignoreCase ? "i" : "")
103
+ } catch (e) {
104
+ throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
105
+ }
106
+ // §17 调用侧拆分:glob 参数支持空格分隔 include !exclude 多模式(include/exclude 求交)。
107
+ let fileTest = null
108
+ if (args.glob) {
109
+ try {
110
+ const parts = splitGlobPatterns(args.glob)
111
+ if (parts.length > 0) fileTest = compileGlobMatchers(parts).test
112
+ } catch (e) {
113
+ throw new Error(`grep glob /${args.glob}/ is invalid: ${e.message}`, { cause: e })
114
+ }
115
+ }
116
+ const before = Math.max(0, Math.floor(args.before ?? 0))
117
+ const after = Math.max(0, Math.floor(args.after ?? 0))
118
+ const wantCtx = before > 0 || after > 0
119
+ const hits = [] // { file, line(1-based), text }
120
+ const fileLines = new Map() // file -> string[] (cached only when context lines requested)
121
+
122
+ async function search(file) {
123
+ let content
124
+ try {
125
+ // Large file guard: skip files over 10MB to prevent OOM
126
+ const fst = await stat(file)
127
+ if (fst.size > 10_000_000) return
128
+ content = normalizeEOL(await readFile(file, "utf8"))
129
+ } catch {
130
+ return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
131
+ }
132
+ const lines = content.split("\n")
133
+ if (wantCtx) fileLines.set(file, lines)
134
+ for (let i = 0; i < lines.length; i++) {
135
+ if (regex.test(lines[i])) {
136
+ hits.push({ file, line: i + 1, text: lines[i] })
137
+ if (hits.length >= 200) return
138
+ }
139
+ }
140
+ }
141
+
142
+ async function walk(target, rel) {
143
+ if (hits.length >= 200) return
144
+ // Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
145
+ let s
146
+ try { s = await lstat(target) } catch { return }
147
+ if (!s.isDirectory()) {
148
+ // rel 为相对搜索基的路径(排除前缀如 !test/** 必须按目录路径作用——不能用裸文件名);
149
+ // 单文件目标(path 指向文件)时 rel 为空 → 退化为按文件名过滤(既有语义)。
150
+ if (!fileTest || fileTest(rel || target.split(/[\\/]/).pop())) await search(target)
151
+ return
152
+ }
153
+ let entries
154
+ try {
155
+ entries = await readdir(target, { withFileTypes: true })
156
+ } catch {
157
+ return
158
+ }
159
+ for (const e of entries) {
160
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
161
+ await walk(join(target, e.name), rel ? `${rel}/${e.name}` : e.name)
162
+ }
163
+ }
164
+
165
+ await walk(base, "")
166
+ if (hits.length === 0) return "(no matches)"
167
+
168
+ // No context: keep original path:line: content format
169
+ if (!wantCtx) {
170
+ return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
171
+ }
172
+
173
+ // With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
174
+ const fileMatched = new Map() // file -> Set<line>
175
+ for (const h of hits) {
176
+ if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
177
+ fileMatched.get(h.file).add(h.line)
178
+ }
179
+ const out = []
180
+ for (const [file, matchedLines] of fileMatched) {
181
+ const lines = fileLines.get(file) ?? []
182
+ const lineSet = new Set()
183
+ for (const ml of matchedLines) {
184
+ for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
185
+ }
186
+ for (const l of [...lineSet].sort((a, b) => a - b)) {
187
+ const sep = matchedLines.has(l) ? ":" : "-"
188
+ out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
189
+ }
190
+ }
191
+ return truncate(out.join("\n"))
192
+ },
193
+ }
194
+
195
+ export const lsTool = {
196
+ name: "ls",
197
+ description: DESC("ls"),
198
+ parameters: {
199
+ type: "object",
200
+ properties: {
201
+ path: { type: "string", description: "Directory path (default cwd)" },
202
+ filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
203
+ },
204
+ },
205
+ readonly: true,
206
+ async execute(args, ctx) {
207
+ const abs = resolveInCwd(ctx, args.path ?? ".")
208
+ const filterRe = args.filter ? globToRegex(args.filter) : null
209
+ let entries
210
+ try {
211
+ entries = await readdir(abs, { withFileTypes: true })
212
+ } catch (e) {
213
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`, { cause: e })
214
+ throw e
215
+ }
216
+ const rows = await Promise.all(
217
+ entries
218
+ .filter((e) => !filterRe || filterRe.test(e.name))
219
+ .slice(0, 500)
220
+ .map(async (e) => {
221
+ const s = await stat(join(abs, e.name)).catch(() => null)
222
+ const isDir = e.isDirectory()
223
+ return {
224
+ dir: isDir,
225
+ name: e.name + (isDir ? "/" : ""),
226
+ size: s?.size ?? 0,
227
+ mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
228
+ }
229
+ }),
230
+ )
231
+ rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
232
+ if (rows.length === 0) return "(no entries)"
233
+ const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
234
+ return truncate(out.join("\n"))
235
+ },
236
+ }
@@ -363,19 +363,20 @@ export function detectDanger(command) {
363
363
  }
364
364
 
365
365
 
366
- /** Convert glob pattern to regex */
367
- export function globToRegex(pattern) {
368
- // Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
369
- // come from model output or the filesystem) safe as **/ and ** placeholders.
370
- const DS = "\u0001", DP = "\u0002"
371
- const escaped = pattern
372
- .replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
373
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
374
- .replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
375
- .replace(new RegExp(DS, "g"), "(?:.+/)?")
376
- .replace(new RegExp(DP, "g"), ".*")
377
- return new RegExp(`^${escaped}$`)
378
- }
366
+ /** Glob dialect (TOOLS.md §17) lives in glob-dialect.mjs single authority for
367
+ * brace expansion / exclusion / explicit syntax errors (extracted 2026-09-06 —
368
+ * advisor #5: shared.mjs exceeded 500 lines). Re-exported so existing importers
369
+ * (system.mjs tools, ls filter, tests) keep their import paths unchanged. */
370
+ export {
371
+ globToRegex,
372
+ splitGlobPatterns,
373
+ compileGlobMatchers,
374
+ GLOB_EXTGLOB_ERROR,
375
+ GLOB_EMPTY_BRACE_ERROR,
376
+ GLOB_UNCLOSED_BRACE_ERROR,
377
+ GLOB_NESTED_BRACE_ERROR,
378
+ } from "./glob-dialect.mjs"
379
+
379
380
 
380
381
  /** Decode a numeric HTML entity to its code point — invalid/out-of-range
381
382
  * values (e.g. &#999999999999;) must not throw RangeError; keep the source
@@ -0,0 +1,22 @@
1
+ Wait until a condition becomes true — polls the condition every interval_ms and returns as soon as it holds or the timeout_ms ceiling passes. Use it ONLY for genuinely asynchronous waits: an async subagent/consult still settling, a file appearing, a port opening. Synchronous tools (advisor, blocking subagent spawn) return when done — waiting after them is NOT needed and wastes time.
2
+
3
+ Parameters:
4
+ - condition (required): a semantic condition expression:
5
+ - `advisor settled` — an in-flight advisor review session has finished (async advisor-role children settled)
6
+ - `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled
7
+ - `consult done` — every consult_start session has drained
8
+ - `file exists:path` — the file at path exists (path relative to cwd)
9
+ - `port open:N` — something is listening on 127.0.0.1 port N
10
+ - The agent-internal conditions (advisor / subagent / consult) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for. An unknown condition is an explicit error (`wait_for: unsupported condition "..."` — the supported forms are listed above), never a silent wait.
11
+ - interval_ms: poll interval (default 1000, floor 100 — polling never busy-spins)
12
+ - timeout_ms: overall ceiling (default 30000; config.json `agent.waitForTimeoutMs` overrides the default; hard cap 600000 like the execute tool)
13
+
14
+ Returns:
15
+ - `wait_for: condition satisfied after Nms (N checks): "<condition>"` — condition held
16
+ - `wait_for: timed out after Nms waiting for "<condition>" ...` — ceiling passed with the condition still false (never burns beyond the ceiling)
17
+ - interrupts (Ctrl+C / cancel) exit immediately; malformed conditions and unsupported syntax error out explicitly
18
+
19
+ Notes:
20
+ - Read-only and non-destructive — it only observes (agent pools, the filesystem, a local port probe).
21
+ - Blocking by design — call it ALONE in a turn, not batched with calls that depend on its result.
22
+ - Do not use it to wait after synchronous tools (advisor / blocking subagent spawn / verify return only when done).