@yanzc/claude-pro 0.0.0-stage → 0.4.0

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 (58) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +314 -3
  4. package/bin/claude-pro.js +32 -0
  5. package/package.json +40 -6
  6. package/src/agent.js +752 -0
  7. package/src/agents.js +186 -0
  8. package/src/api.js +488 -0
  9. package/src/cache.js +150 -0
  10. package/src/checkpoints.js +184 -0
  11. package/src/claude-code.js +60 -0
  12. package/src/codex/client.js +300 -0
  13. package/src/codex/translate-request.js +239 -0
  14. package/src/codex/translate-stream.js +328 -0
  15. package/src/commands.js +803 -0
  16. package/src/config.js +453 -0
  17. package/src/context.js +109 -0
  18. package/src/filestate.js +57 -0
  19. package/src/gateway.js +110 -0
  20. package/src/hooks.js +81 -0
  21. package/src/main.js +193 -0
  22. package/src/mcp.js +290 -0
  23. package/src/modes.js +154 -0
  24. package/src/oauth.js +287 -0
  25. package/src/peers.js +156 -0
  26. package/src/print.js +106 -0
  27. package/src/prompts.js +179 -0
  28. package/src/ratelimit.js +244 -0
  29. package/src/restart.js +115 -0
  30. package/src/runtime.js +162 -0
  31. package/src/selfcheck.js +71 -0
  32. package/src/session.js +139 -0
  33. package/src/supervisor.js +110 -0
  34. package/src/tasks.js +76 -0
  35. package/src/tools/bash.js +227 -0
  36. package/src/tools/effort.js +41 -0
  37. package/src/tools/files.js +352 -0
  38. package/src/tools/index.js +52 -0
  39. package/src/tools/interaction.js +157 -0
  40. package/src/tools/notebook.js +91 -0
  41. package/src/tools/peers.js +45 -0
  42. package/src/tools/restart.js +50 -0
  43. package/src/tools/search.js +458 -0
  44. package/src/tools/skill.js +32 -0
  45. package/src/tools/task.js +139 -0
  46. package/src/tools/web.js +283 -0
  47. package/src/ui/ansi.js +228 -0
  48. package/src/ui/dialogs.js +122 -0
  49. package/src/ui/editor.js +389 -0
  50. package/src/ui/markdown.js +249 -0
  51. package/src/ui/render.js +155 -0
  52. package/src/ui/repl.js +1450 -0
  53. package/src/ui/screen.js +259 -0
  54. package/src/updater.js +93 -0
  55. package/src/utils/clipboard.js +197 -0
  56. package/src/utils/diff.js +95 -0
  57. package/src/utils/paths.js +37 -0
  58. package/src/utils/shell.js +251 -0
@@ -0,0 +1,458 @@
1
+ // Glob and Grep tools. Grep uses ripgrep when available and otherwise a JS scanner that follows
2
+ // ripgrep's rules: .gitignore files are honoured, --glob overrides win (last match, "!" excludes),
3
+ // ^ and $ anchor at line boundaries, hidden files are searched and binary files are skipped.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { resolvePath, displayPath } from '../utils/paths.js';
9
+ import { ripgrepPath } from '../utils/shell.js';
10
+
11
+ const IGNORED_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '__pycache__', '.venv', 'dist-newstyle']);
12
+
13
+ export const GlobTool = {
14
+ name: 'Glob',
15
+ readOnly: true,
16
+ description: `- Fast file pattern matching tool that works with any codebase size
17
+ - Supports glob patterns like "**/*.js" or "src/**/*.ts"
18
+ - Returns matching file paths sorted by modification time (most recent first)
19
+ - Use this tool when you need to find files by name patterns
20
+ - When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
21
+ - You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.`,
22
+ input_schema: {
23
+ type: 'object',
24
+ properties: {
25
+ pattern: { type: 'string', description: 'The glob pattern to match files against' },
26
+ path: { type: 'string', description: 'The directory to search in. If not specified, the current working directory will be used. Omit this field to use the default directory; do not pass "undefined" or "null".' },
27
+ },
28
+ required: ['pattern'],
29
+ },
30
+ summarize(input, ctx) {
31
+ return input.path ? `pattern: "${input.pattern}", path: "${displayPath(resolvePath(input.path, ctx.cwd), ctx.cwd)}"` : `pattern: "${input.pattern}"`;
32
+ },
33
+ async call(input, ctx) {
34
+ const base = resolvePath(input.path && input.path !== 'undefined' && input.path !== 'null' ? input.path : ctx.cwd, ctx.cwd);
35
+ if (!fs.existsSync(base)) return { isError: true, content: `Directory does not exist: ${base}` };
36
+ let pattern = String(input.pattern || '').replace(/\\/g, '/');
37
+ // Absolute patterns inside the base dir become relative.
38
+ if (path.isAbsolute(pattern)) {
39
+ const rel = path.relative(base, pattern).replace(/\\/g, '/');
40
+ if (!rel.startsWith('..')) pattern = rel;
41
+ }
42
+ const explicit = [...IGNORED_DIRS].filter((d) => pattern.includes(d));
43
+ const exclude = (p) => {
44
+ const name = path.basename(p);
45
+ return IGNORED_DIRS.has(name) && !explicit.includes(name);
46
+ };
47
+ const files = [];
48
+ const start = Date.now();
49
+ try {
50
+ for await (const f of fs.promises.glob(pattern, { cwd: base, exclude })) {
51
+ if (ctx.signal?.aborted) break;
52
+ const abs = path.resolve(base, f);
53
+ try {
54
+ const st = fs.statSync(abs);
55
+ if (st.isFile()) files.push({ abs, mtime: st.mtimeMs });
56
+ } catch { /* vanished */ }
57
+ if (files.length >= 20_000) break;
58
+ }
59
+ } catch (e) {
60
+ return { isError: true, content: `Glob failed: ${e.message}` };
61
+ }
62
+ files.sort((a, b) => b.mtime - a.mtime);
63
+ const limit = 1000;
64
+ const shown = files.slice(0, limit).map((f) => f.abs);
65
+ let content = shown.length ? shown.join('\n') : 'No files found';
66
+ if (files.length > limit) content += `\n(Results are truncated: showing ${limit} of ${files.length}. Consider using a more specific path or pattern.)`;
67
+ return { content, display: { summary: `Found ${files.length} file${files.length === 1 ? '' : 's'} in ${Date.now() - start}ms` } };
68
+ },
69
+ };
70
+
71
+ // rg --type names (plus a few aliases) → file extensions, for the JS scanner.
72
+ const TYPE_EXT = {
73
+ js: ['js', 'jsx', 'mjs', 'cjs'], ts: ['ts', 'tsx', 'mts', 'cts'], py: ['py', 'pyi'], rust: ['rs'], go: ['go'],
74
+ java: ['java'], c: ['c', 'h'], cpp: ['cpp', 'cc', 'cxx', 'hpp', 'hh', 'h'], cs: ['cs'], csharp: ['cs'], rb: ['rb'], ruby: ['rb'],
75
+ php: ['php'], swift: ['swift'], kotlin: ['kt', 'kts'], css: ['css', 'scss', 'less'], html: ['html', 'htm'], json: ['json'],
76
+ md: ['md', 'markdown'], markdown: ['md', 'markdown'], yaml: ['yaml', 'yml'], sh: ['sh', 'bash'], sql: ['sql'], vue: ['vue'],
77
+ xml: ['xml'], toml: ['toml'], txt: ['txt'], lua: ['lua'], dart: ['dart'], scala: ['scala', 'sc'], svelte: ['svelte'],
78
+ ps: ['ps1', 'psm1', 'psd1'], bat: ['bat', 'cmd'],
79
+ };
80
+
81
+ const MAX_COLUMNS = 500; // same as the rg flags below: --max-columns 500 --max-columns-preview
82
+ const MAX_FILE_BYTES = 20 * 1024 * 1024; // the JS scanner reads whole files, so huge ones are skipped
83
+ const CONCURRENCY = 16;
84
+
85
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
86
+
87
+ // Compile a gitignore/ripgrep glob: `*` and `?` stay inside one path segment, a whole `**`
88
+ // segment spans directories, `{a,b}` and `[...]` work, and dotfiles are not special.
89
+ export function globToRegExp(glob, ignoreCase = false) {
90
+ let re = '';
91
+ let braces = 0;
92
+ for (let i = 0; i < glob.length; i++) {
93
+ const ch = glob[i];
94
+ if (ch === '*') {
95
+ const segmentStart = i === 0 || glob[i - 1] === '/';
96
+ if (segmentStart && glob[i + 1] === '*' && (i + 2 === glob.length || glob[i + 2] === '/')) {
97
+ if (i + 2 === glob.length) { re += '.*'; i += 1; } else { re += '(?:.*/)?'; i += 2; }
98
+ } else {
99
+ while (glob[i + 1] === '*') i++;
100
+ re += '[^/]*';
101
+ }
102
+ } else if (ch === '?') {
103
+ re += '[^/]';
104
+ } else if (ch === '[') {
105
+ let j = i + 1;
106
+ const negate = glob[j] === '!' || glob[j] === '^';
107
+ if (negate) j++;
108
+ const bodyStart = j;
109
+ if (glob[j] === ']') j++;
110
+ while (j < glob.length && glob[j] !== ']') j++;
111
+ if (j >= glob.length) { re += '\\['; continue; }
112
+ const body = glob.slice(bodyStart, j).replace(/[\\\][^]/g, '\\$&');
113
+ re += negate ? `[^/${body}]` : `[${body}]`;
114
+ i = j;
115
+ } else if (ch === '{') {
116
+ braces++;
117
+ re += '(?:';
118
+ } else if (ch === '}' && braces) {
119
+ braces--;
120
+ re += ')';
121
+ } else if (ch === ',' && braces) {
122
+ re += '|';
123
+ } else if (ch === '\\' && i + 1 < glob.length) {
124
+ re += escapeRegExp(glob[++i]);
125
+ } else {
126
+ re += escapeRegExp(ch);
127
+ }
128
+ }
129
+ if (braces) throw new Error(`unclosed alternate group in glob: ${glob}`);
130
+ return new RegExp(`^${re}$`, ignoreCase ? 'i' : '');
131
+ }
132
+
133
+ // A .gitignore line or a --glob override. "!" flips it, a trailing "/" limits it to directories,
134
+ // and any other "/" anchors it to its base directory instead of matching basenames at any depth.
135
+ function parseRule(text, ignoreCase = false) {
136
+ let pattern = text;
137
+ const negate = pattern.startsWith('!');
138
+ if (negate) pattern = pattern.slice(1);
139
+ else if (pattern.startsWith('\\!') || pattern.startsWith('\\#')) pattern = pattern.slice(1);
140
+ const dirOnly = pattern.endsWith('/');
141
+ pattern = pattern.replace(/\/+$/, '');
142
+ const anchored = pattern.includes('/');
143
+ pattern = pattern.replace(/^(?:\.\/|\/)/, '');
144
+ if (!pattern) return null;
145
+ return { source: text, negate, dirOnly, anchored, re: globToRegExp(pattern, ignoreCase) };
146
+ }
147
+
148
+ // The last matching rule decides, as in .gitignore files and rg --glob.
149
+ function lastRule(rules, rel, name, isDir) {
150
+ for (let i = rules.length - 1; i >= 0; i--) {
151
+ const r = rules[i];
152
+ if ((!r.dirOnly || isDir) && r.re.test(r.anchored ? rel : name)) return r;
153
+ }
154
+ return null;
155
+ }
156
+
157
+ // "*.js,*.ts" or "*.js *.ts" are two globs; commas inside {...} are not separators.
158
+ function splitGlobs(glob) {
159
+ if (!glob) return [];
160
+ const globs = String(glob).split(/[ ,]+(?![^{]*})/).filter(Boolean);
161
+ return process.platform === 'win32' ? globs.map((g) => g.replace(/\\/g, '/')) : globs;
162
+ }
163
+
164
+ const IGNORE_FILES = ['.gitignore', '.ignore', '.rgignore']; // lowest precedence first
165
+
166
+ async function readIgnoreFile(dir, name) {
167
+ let text;
168
+ try { text = await fs.promises.readFile(path.join(dir, name), 'utf8'); } catch { return null; }
169
+ const ignoreCase = name === '.gitignore' && process.platform === 'win32'; // git for Windows defaults to core.ignorecase
170
+ const rules = [];
171
+ for (const raw of text.split(/\r?\n/)) {
172
+ const line = raw.replace(/(?<!\\)\s+$/, '');
173
+ if (!line || line.startsWith('#')) continue;
174
+ try {
175
+ const rule = parseRule(line, ignoreCase);
176
+ if (rule) rules.push(rule);
177
+ } catch { /* git skips patterns it cannot parse */ }
178
+ }
179
+ return rules.length ? { dir, rules } : null;
180
+ }
181
+
182
+ // Ignore files of one directory, lowest precedence first. Like ripgrep, .gitignore only counts
183
+ // inside a git work tree; .ignore and .rgignore always count.
184
+ async function ignoreScopes(dir, inRepo) {
185
+ const names = IGNORE_FILES.filter((n) => inRepo || n !== '.gitignore');
186
+ return (await Promise.all(names.map((n) => readIgnoreFile(dir, n)))).filter(Boolean);
187
+ }
188
+
189
+ // Ignore files above the search root apply too, up to the top of the git work tree.
190
+ async function ancestorScopes(base) {
191
+ const above = [];
192
+ for (let d = base; ; d = path.dirname(d)) {
193
+ if (fs.existsSync(path.join(d, '.git'))) {
194
+ const scopes = [];
195
+ for (const dir of above.reverse()) scopes.push(...await ignoreScopes(dir, true));
196
+ return { inRepo: true, scopes };
197
+ }
198
+ const parent = path.dirname(d);
199
+ if (parent === d) return { inRepo: false, scopes: [] };
200
+ above.push(parent);
201
+ }
202
+ }
203
+
204
+ // Deeper .gitignore files take precedence over the ones above them.
205
+ function isIgnored(scopes, abs, name, isDir) {
206
+ for (let i = scopes.length - 1; i >= 0; i--) {
207
+ const rule = lastRule(scopes[i].rules, path.relative(scopes[i].dir, abs).replace(/\\/g, '/'), name, isDir);
208
+ if (rule) return !rule.negate;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ async function collectFiles(base, globs, exts, signal) {
214
+ if (!(await fs.promises.stat(base)).isDirectory()) return [base];
215
+ const overrides = globs.map((g) => parseRule(g)).filter(Boolean);
216
+ const whitelist = overrides.some((r) => !r.negate);
217
+ // A directory that is skipped by default (node_modules, ...) is searched when a glob names it.
218
+ const named = new Set([...IGNORED_DIRS].filter((d) => d !== '.git' && overrides.some((r) => !r.negate && r.source.includes(d))));
219
+ const files = [];
220
+ const walk = async (dir, scopes, inRepo) => {
221
+ if (signal?.aborted) return;
222
+ let entries;
223
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
224
+ inRepo ||= entries.some((e) => e.name === '.git');
225
+ if (entries.some((e) => IGNORE_FILES.includes(e.name))) scopes = [...scopes, ...await ignoreScopes(dir, inRepo)];
226
+ for (const e of entries) {
227
+ const isDir = e.isDirectory();
228
+ if ((!isDir && !e.isFile()) || e.name === '.git') continue;
229
+ const abs = path.join(dir, e.name);
230
+ const override = lastRule(overrides, path.relative(base, abs).replace(/\\/g, '/'), e.name, isDir);
231
+ if (override?.negate) continue;
232
+ if (isDir) {
233
+ const skip = !override && !named.has(e.name) && (IGNORED_DIRS.has(e.name) || isIgnored(scopes, abs, e.name, true));
234
+ if (!skip) await walk(abs, scopes, inRepo);
235
+ } else if (override || (!whitelist && !isIgnored(scopes, abs, e.name, false))) {
236
+ if (!exts || exts.includes(path.extname(e.name).slice(1).toLowerCase())) files.push(abs);
237
+ }
238
+ }
239
+ };
240
+ const above = await ancestorScopes(base);
241
+ await walk(base, above.scopes, above.inRepo);
242
+ return files;
243
+ }
244
+
245
+ // Text of a file, or null for binary files. UTF-16 with a BOM is decoded like ripgrep does.
246
+ function decodeText(buf) {
247
+ if (buf[0] === 0xFF && buf[1] === 0xFE) return buf.toString('utf16le', 2);
248
+ if (buf[0] === 0xFE && buf[1] === 0xFF) return new TextDecoder('utf-16be').decode(buf.subarray(2));
249
+ if (buf.subarray(0, 8000).includes(0)) return null;
250
+ const text = buf.toString('utf8');
251
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
252
+ }
253
+
254
+ const clip = (line) => (line.length > MAX_COLUMNS ? `${line.slice(0, MAX_COLUMNS)} [... omitted end of long line]` : line);
255
+
256
+ function runRg(bin, args, cwd, signal) {
257
+ return new Promise((resolve) => {
258
+ const child = spawn(bin, args, { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
259
+ let out = '', err = '';
260
+ child.stdout.on('data', (d) => { if (out.length < 20_000_000) out += d; });
261
+ child.stderr.on('data', (d) => { err += d; });
262
+ const onAbort = () => child.kill();
263
+ signal?.addEventListener('abort', onAbort, { once: true });
264
+ child.on('close', (code) => { signal?.removeEventListener('abort', onAbort); resolve({ code, out, err }); });
265
+ child.on('error', (e) => { signal?.removeEventListener('abort', onAbort); resolve({ code: 2, out: '', err: e.message, spawnError: true }); });
266
+ });
267
+ }
268
+
269
+ // JS scanner used when ripgrep isn't available. Returns output lines in rg's format.
270
+ async function jsGrep(input, base, mode, signal) {
271
+ const exts = input.type ? TYPE_EXT[String(input.type).toLowerCase()] : null;
272
+ if (input.type && !exts) throw new Error(`unrecognized file type: ${input.type} (known types: ${Object.keys(TYPE_EXT).join(', ')})`);
273
+ const flags = input['-i'] ? 'i' : '';
274
+ const dotAll = input.multiline ? 's' : '';
275
+ // ripgrep treats ^ and $ as line anchors, so whole-file matching needs the m flag.
276
+ const anyRe = new RegExp(input.pattern, `${flags}m${dotAll}`);
277
+ const allRe = new RegExp(input.pattern, `${flags}gm${dotAll}`);
278
+ const lineRe = new RegExp(input.pattern, flags);
279
+ const content = mode === 'content';
280
+ const before = content ? Math.max(0, Number(input['-B'] ?? input['-C'] ?? 0) || 0) : 0;
281
+ const after = content ? Math.max(0, Number(input['-A'] ?? input['-C'] ?? 0) || 0) : 0;
282
+ const files = await collectFiles(base, splitGlobs(input.glob), exts, signal);
283
+ let skipped = 0;
284
+
285
+ const searchFile = async (file) => {
286
+ let text;
287
+ try {
288
+ if ((await fs.promises.stat(file)).size > MAX_FILE_BYTES) { skipped++; return null; }
289
+ text = decodeText(await fs.promises.readFile(file));
290
+ } catch { return null; }
291
+ if (text === null) return null;
292
+ if (text.includes('\r')) text = text.replace(/\r\n/g, '\n');
293
+ // Cheap whole-file check first; it can only over-match, so lines are checked below.
294
+ if (!anyRe.test(text)) return null;
295
+ const lines = text.split('\n');
296
+ if (lines.length > 1 && lines.at(-1) === '') lines.pop();
297
+ let hits;
298
+ if (input.multiline) {
299
+ const starts = [0];
300
+ for (let i = text.indexOf('\n'); i !== -1 && i + 1 < text.length; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
301
+ const lineAt = (pos) => {
302
+ let lo = 0, hi = starts.length - 1;
303
+ while (lo < hi) {
304
+ const mid = (lo + hi + 1) >> 1;
305
+ if (starts[mid] <= pos) lo = mid; else hi = mid - 1;
306
+ }
307
+ return lo;
308
+ };
309
+ const set = new Set();
310
+ for (const m of text.matchAll(allRe)) {
311
+ const last = lineAt(m.index + Math.max(0, m[0].length - 1));
312
+ for (let i = lineAt(m.index); i <= last; i++) set.add(i);
313
+ }
314
+ hits = [...set];
315
+ } else {
316
+ hits = [];
317
+ for (let i = 0; i < lines.length; i++) if (lineRe.test(lines[i])) hits.push(i);
318
+ }
319
+ if (!hits.length) return null;
320
+ if (mode === 'files_with_matches') return [file];
321
+ if (mode === 'count') return [`${file}:${hits.length}`];
322
+ const isHit = new Set(hits);
323
+ const out = [];
324
+ let end = -1;
325
+ for (const h of hits) {
326
+ const from = Math.max(0, h - before, end + 1);
327
+ const to = Math.min(lines.length - 1, h + after);
328
+ if ((before || after) && out.length && from > end + 1) out.push('--');
329
+ for (let i = from; i <= to; i++) {
330
+ const sep = isHit.has(i) ? ':' : '-';
331
+ out.push(`${file}${sep}${input['-n'] === false ? '' : `${i + 1}${sep}`}${clip(lines[i])}`);
332
+ }
333
+ end = Math.max(end, to);
334
+ }
335
+ return out;
336
+ };
337
+
338
+ const results = new Array(files.length);
339
+ let next = 0;
340
+ const worker = async () => {
341
+ while (next < files.length && !signal?.aborted) {
342
+ const i = next++;
343
+ results[i] = await searchFile(files[i]);
344
+ }
345
+ };
346
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, files.length) }, worker));
347
+ const lines = [];
348
+ for (const r of results) {
349
+ if (!r) continue;
350
+ if ((before || after) && lines.length) lines.push('--');
351
+ for (const l of r) lines.push(l);
352
+ }
353
+ return { lines, skipped };
354
+ }
355
+
356
+ export const GrepTool = {
357
+ name: 'Grep',
358
+ readOnly: true,
359
+ description: `A powerful search tool built on ripgrep
360
+
361
+ Usage:
362
+ - ALWAYS use Grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a Bash command.
363
+ - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
364
+ - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
365
+ - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
366
+ - Use Task tool for open-ended searches requiring multiple rounds
367
+ - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
368
+ - Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\``,
369
+ input_schema: {
370
+ type: 'object',
371
+ properties: {
372
+ pattern: { type: 'string', description: 'The regular expression pattern to search for in file contents' },
373
+ path: { type: 'string', description: 'File or directory to search in. Defaults to current working directory.' },
374
+ glob: { type: 'string', description: 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")' },
375
+ output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'], description: 'Output mode. Defaults to "files_with_matches".' },
376
+ '-B': { type: 'number', description: 'Lines to show before each match. Requires output_mode "content".' },
377
+ '-A': { type: 'number', description: 'Lines to show after each match. Requires output_mode "content".' },
378
+ '-C': { type: 'number', description: 'Lines to show before and after each match. Requires output_mode "content".' },
379
+ '-n': { type: 'boolean', description: 'Show line numbers in output. Requires output_mode "content". Defaults to true.' },
380
+ '-i': { type: 'boolean', description: 'Case insensitive search' },
381
+ type: { type: 'string', description: 'File type to search (rg --type). Common types: js, py, rust, go, java, etc.' },
382
+ head_limit: { type: 'number', description: 'Limit output to first N lines/entries. Defaults to 1000; pass 0 for unlimited.' },
383
+ offset: { type: 'number', description: 'Skip first N lines/entries before applying head_limit.' },
384
+ multiline: { type: 'boolean', description: 'Enable multiline mode where . matches newlines and patterns can span lines. Default: false.' },
385
+ },
386
+ required: ['pattern'],
387
+ },
388
+ summarize(input, ctx) {
389
+ let s = `pattern: "${input.pattern}"`;
390
+ if (input.path) s += `, path: "${displayPath(resolvePath(input.path, ctx.cwd), ctx.cwd)}"`;
391
+ if (input.glob) s += `, glob: "${input.glob}"`;
392
+ if (input.type) s += `, type: "${input.type}"`;
393
+ if (input.output_mode && input.output_mode !== 'files_with_matches') s += `, output_mode: "${input.output_mode}"`;
394
+ return s;
395
+ },
396
+ async call(input, ctx) {
397
+ const base = resolvePath(input.path || ctx.cwd, ctx.cwd);
398
+ if (!fs.existsSync(base)) return { isError: true, content: `Path does not exist: ${base}` };
399
+ const mode = input.output_mode || 'files_with_matches';
400
+ let lines = null;
401
+ let note = '';
402
+ const rg = ripgrepPath(ctx.config);
403
+ if (rg) {
404
+ // --crlf lets `$` match before \r\n, so line-end anchors also work in Windows (CRLF) files.
405
+ const args = ['--no-config', '--hidden', '--crlf', '--glob', '!.git', '--max-columns', String(MAX_COLUMNS), '--max-columns-preview', '--color', 'never'];
406
+ if (input['-i']) args.push('-i');
407
+ if (input.multiline) args.push('-U', '--multiline-dotall');
408
+ if (input.type) args.push('--type', input.type);
409
+ for (const g of splitGlobs(input.glob)) args.push('--glob', g);
410
+ if (mode === 'files_with_matches') args.push('-l');
411
+ else if (mode === 'count') args.push('-c');
412
+ else {
413
+ if (input['-n'] !== false) args.push('-n');
414
+ if (input['-C'] != null) args.push('-C', String(input['-C']));
415
+ if (input['-A'] != null) args.push('-A', String(input['-A']));
416
+ if (input['-B'] != null) args.push('-B', String(input['-B']));
417
+ }
418
+ args.push('-e', input.pattern, '--', base);
419
+ const r = await runRg(rg, args, ctx.cwd, ctx.signal);
420
+ if (!r.spawnError) {
421
+ if (r.code === 2 && !r.out) return { isError: true, content: `ripgrep error: ${r.err.trim()}` };
422
+ lines = r.out.split(/\r?\n/).filter(Boolean);
423
+ }
424
+ }
425
+ if (!lines) {
426
+ try {
427
+ const found = await jsGrep(input, base, mode, ctx.signal);
428
+ lines = found.lines;
429
+ if (found.skipped) note = `\n\n(${found.skipped} file${found.skipped === 1 ? '' : 's'} larger than ${MAX_FILE_BYTES / 1024 / 1024}MB not searched; install ripgrep to search them)`;
430
+ } catch (e) {
431
+ return { isError: true, content: `Grep failed: ${e.message}` };
432
+ }
433
+ }
434
+ if (mode === 'files_with_matches') {
435
+ lines = lines.map((f) => ({ f, t: (() => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } })() }))
436
+ .sort((a, b) => b.t - a.t).map((x) => x.f);
437
+ }
438
+ const total = lines.length;
439
+ const offset = Math.max(0, input.offset || 0);
440
+ const limit = input.head_limit === 0 ? Infinity : (input.head_limit || 1000);
441
+ const page = lines.slice(offset, offset + limit);
442
+ let content;
443
+ let summary;
444
+ if (mode === 'files_with_matches') {
445
+ content = total ? `Found ${total} file${total === 1 ? '' : 's'}\n${page.join('\n')}` : 'No files found';
446
+ summary = `Found ${total} file${total === 1 ? '' : 's'}`;
447
+ } else if (mode === 'count') {
448
+ const sum = lines.reduce((n, l) => n + (parseInt(l.split(':').pop(), 10) || 0), 0);
449
+ content = total ? page.join('\n') + `\n\nFound ${sum} total occurrences across ${total} files.` : 'No matches found';
450
+ summary = `Found ${sum} match${sum === 1 ? '' : 'es'} in ${total} file${total === 1 ? '' : 's'}`;
451
+ } else {
452
+ content = total ? page.join('\n') : 'No matches found';
453
+ summary = `Found ${total} line${total === 1 ? '' : 's'}`;
454
+ }
455
+ if (total > offset + page.length) content += `\n\n(Showing ${page.length} of ${total} results. Use offset/head_limit to page, or narrow the search.)`;
456
+ return { content: content + note, display: { summary } };
457
+ },
458
+ };
@@ -0,0 +1,32 @@
1
+ // Skill tool: lets the model load a skill (packaged instructions for one kind of task) when
2
+ // the task at hand matches its description.
3
+
4
+ import { renderSkill } from '../agents.js';
5
+
6
+ export function makeSkillTool(skills) {
7
+ return {
8
+ name: 'Skill',
9
+ readOnly: true,
10
+ description: () => `Load a skill: a packaged set of instructions for a particular kind of task.
11
+
12
+ Available skills:
13
+ ${[...skills.values()].map((s) => `- ${s.name}: ${s.description}`).join('\n')}
14
+
15
+ When the task matches a skill's description, call this tool with the skill name BEFORE doing the work, then follow the instructions it returns. Do not mention a skill without calling it, and do not call a skill that is already loaded in this conversation. Pass arguments in \`args\` when the user gave any.`,
16
+ input_schema: {
17
+ type: 'object',
18
+ properties: {
19
+ skill: { type: 'string', description: 'The skill name, e.g. "pdf"' },
20
+ args: { type: 'string', description: 'Optional arguments for the skill' },
21
+ },
22
+ required: ['skill'],
23
+ },
24
+ summarize(input) { return input.skill || ''; },
25
+ async call(input) {
26
+ const name = String(input.skill || '').replace(/^\//, '');
27
+ const skill = skills.get(name);
28
+ if (!skill) return { isError: true, content: `Unknown skill: ${name}. Available skills: ${[...skills.keys()].join(', ') || 'none'}` };
29
+ return { content: renderSkill(skill, input.args || ''), display: { summary: `Loaded skill ${skill.name}` } };
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,139 @@
1
+ // Task tool: launches a sub-agent with its own context window and tool set, in the
2
+ // foreground or (run_in_background) as a background task. TaskOutput / TaskStop manage
3
+ // background tasks.
4
+
5
+ import { taskResultText } from '../tasks.js';
6
+
7
+ // Wait for `promise`, at most `ms`, or until `signal` aborts. The timer is always cleared so a
8
+ // finished wait never keeps the process alive.
9
+ function waitFor(promise, ms, signal) {
10
+ let timer, onAbort;
11
+ const limit = new Promise((resolve) => {
12
+ timer = setTimeout(resolve, ms);
13
+ onAbort = () => resolve();
14
+ signal?.addEventListener('abort', onAbort, { once: true });
15
+ });
16
+ return Promise.race([promise, limit]).finally(() => {
17
+ clearTimeout(timer);
18
+ signal?.removeEventListener('abort', onAbort);
19
+ });
20
+ }
21
+
22
+ export function makeTaskTool(agents) {
23
+ const list = [...agents.values()]
24
+ .map((a) => `- ${a.name}: ${a.description} (Tools: ${a.tools === '*' ? 'All tools' : a.tools.join(', ')})`)
25
+ .join('\n');
26
+ return {
27
+ name: 'Task',
28
+ readOnly: true, // sub-agents ask for their own permissions; multiple tasks may run in parallel
29
+ description: `Launch a new agent to handle complex, multi-step tasks autonomously.
30
+
31
+ Available agent types and the tools they have access to:
32
+ ${list}
33
+
34
+ When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
35
+
36
+ When NOT to use the Task tool:
37
+ - If you want to read a specific file path, use the Read or Glob tool instead, to find the match more quickly
38
+ - If you are searching for a specific class definition like "class Foo", use the Glob tool instead
39
+ - If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead
40
+
41
+ Usage notes:
42
+ - Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
43
+ - When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
44
+ - Each agent invocation is stateless. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message.
45
+ - The agent's outputs should generally be trusted
46
+ - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
47
+ - Set run_in_background: true to start the agent and continue working while it runs (e.g. a long investigation, or several independent jobs). You get a task id back immediately, and the agent's final report arrives automatically in a <task-notification> once it finishes; you do not need to poll. Use TaskOutput to check on or wait for it early, and TaskStop to cancel it. Do not start background agents that edit the same files at the same time.`,
48
+ input_schema: {
49
+ type: 'object',
50
+ properties: {
51
+ description: { type: 'string', description: 'A short (3-5 word) description of the task' },
52
+ prompt: { type: 'string', description: 'The task for the agent to perform' },
53
+ subagent_type: { type: 'string', description: 'The type of specialized agent to use for this task' },
54
+ run_in_background: { type: 'boolean', description: 'Run the agent in the background and return immediately with a task id' },
55
+ },
56
+ required: ['description', 'prompt', 'subagent_type'],
57
+ },
58
+ summarize(input) { return `${input.description || ''}${input.run_in_background ? ' (background)' : ''}`; },
59
+ label(input) { return input.subagent_type && input.subagent_type !== 'general-purpose' ? input.subagent_type : 'Task'; },
60
+ async call(input, ctx) {
61
+ const def = agents.get(input.subagent_type) || agents.get('general-purpose');
62
+ if (!agents.has(input.subagent_type) && input.subagent_type) {
63
+ return { isError: true, content: `Agent type '${input.subagent_type}' not found. Available agents: ${[...agents.keys()].join(', ')}` };
64
+ }
65
+ if (input.run_in_background) {
66
+ if (!ctx.tasks) return { isError: true, content: 'Background agents are not available here; run the task in the foreground.' };
67
+ // The task outlives this turn, so it gets its own signal and no live progress display.
68
+ const base = { cwd: ctx.cwd, projectRoot: ctx.projectRoot, config: ctx.config, modes: ctx.modes, checkpoints: ctx.checkpoints, io: ctx.io, depth: ctx.agent?.depth ?? 0 };
69
+ const task = ctx.tasks.start({
70
+ description: input.description || def.name,
71
+ subagentType: def.name,
72
+ run: (signal) => ctx.spawnSubagent({ def, prompt: input.prompt, description: input.description, ctx: { ...base, signal } }),
73
+ });
74
+ return {
75
+ content: `Started background agent ${task.id} (${def.name}): ${input.description}. Its final report will arrive in a <task-notification> when it finishes. Continue with other work; use TaskOutput with task_id "${task.id}" only if you need to wait for it.`,
76
+ display: { summary: `Running in background (${task.id})` },
77
+ };
78
+ }
79
+ const started = Date.now();
80
+ const { text, toolUses, tokens } = await ctx.spawnSubagent({ def, prompt: input.prompt, description: input.description, ctx });
81
+ const secs = Math.round((Date.now() - started) / 100) / 10;
82
+ const kTok = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
83
+ return {
84
+ content: text || '(the agent returned no text)',
85
+ display: { summary: `Done (${toolUses} tool use${toolUses === 1 ? '' : 's'} · ${kTok} tokens · ${secs}s)` },
86
+ };
87
+ },
88
+ };
89
+ }
90
+
91
+ export const TaskOutputTool = {
92
+ name: 'TaskOutput',
93
+ readOnly: true,
94
+ description: `Gets the status and result of a background agent started with Task run_in_background.
95
+ - By default waits (block: true) until the agent finishes or the timeout passes.
96
+ - You normally do not need this: a finished agent's report arrives automatically in a <task-notification>. Use it when you cannot continue without the result.`,
97
+ input_schema: {
98
+ type: 'object',
99
+ properties: {
100
+ task_id: { type: 'string', description: 'The background task id, e.g. agent_1' },
101
+ block: { type: 'boolean', description: 'Wait for the task to finish (default true)' },
102
+ timeout: { type: 'number', description: 'Maximum time to wait in milliseconds (default 120000, max 600000)' },
103
+ },
104
+ required: ['task_id'],
105
+ },
106
+ summarize(input) { return input.task_id; },
107
+ async call(input, ctx) {
108
+ const task = ctx.tasks?.get(input.task_id);
109
+ if (!task) return { isError: true, content: `No background task with id ${input.task_id}. Known: ${ctx.tasks?.list().map((t) => t.id).join(', ') || 'none'}` };
110
+ if (task.status === 'running' && input.block !== false) {
111
+ const timeout = Math.min(Math.max(1000, input.timeout || 120_000), 600_000);
112
+ await waitFor(task.promise, timeout, ctx.signal);
113
+ if (ctx.signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
114
+ }
115
+ if (task.status === 'running') {
116
+ return { content: `<status>running</status>\nTask ${task.id} ("${task.description}") is still running after ${Math.round((Date.now() - task.started) / 1000)}s.`, display: { summary: 'Still running' } };
117
+ }
118
+ // The result is delivered here, so drop the pending notification for it.
119
+ if (ctx.agent) ctx.agent.notifications = ctx.agent.notifications.filter((n) => n.taskId !== task.id);
120
+ return { content: `<status>${task.status}</status>\n<result>\n${taskResultText(task)}\n</result>`, isError: task.status === 'failed', display: { summary: task.status } };
121
+ },
122
+ };
123
+
124
+ export const TaskStopTool = {
125
+ name: 'TaskStop',
126
+ description: 'Stops a running background agent by its task id.',
127
+ input_schema: {
128
+ type: 'object',
129
+ properties: { task_id: { type: 'string', description: 'The background task id to stop' } },
130
+ required: ['task_id'],
131
+ },
132
+ summarize(input) { return input.task_id; },
133
+ async call(input, ctx) {
134
+ const task = ctx.tasks?.get(input.task_id);
135
+ if (!task) return { isError: true, content: `No background task with id ${input.task_id}` };
136
+ if (!ctx.tasks.kill(task.id)) return { content: `Task ${task.id} is not running (status: ${task.status})` };
137
+ return { content: `Stopped background agent ${task.id} ("${task.description}")` };
138
+ },
139
+ };