@cryer/star-cli 0.3.7 → 0.3.9

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.
@@ -0,0 +1,1163 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ MAX_OUTPUT,
4
+ MAX_SNAPSHOT_CONTENT_BYTES,
5
+ WALK_CONCURRENCY,
6
+ bashTool,
7
+ captureSnapshot,
8
+ createIgnorePredicate,
9
+ createTodoTools,
10
+ currentTurnMessageIndex,
11
+ currentTurnSeq,
12
+ decodeEntities,
13
+ defaultAgentTasks,
14
+ defaultTaskManager,
15
+ downsampleImageIfNeeded,
16
+ isSensitivePath,
17
+ matchesGlob,
18
+ nextSnapshotId,
19
+ probeImageDimensions,
20
+ pushSnapshot,
21
+ starHome,
22
+ walkFiles,
23
+ webFetchTool
24
+ } from "./chunk-A5PXPWHB.js";
25
+
26
+ // src/tools/fs/edit.ts
27
+ import { readFile, stat, writeFile } from "fs/promises";
28
+ import path from "path";
29
+ import { z } from "zod";
30
+ var BINARY_PROBE_BYTES = 8192;
31
+ function countOccurrences(haystack, needle) {
32
+ let count = 0;
33
+ let idx = haystack.indexOf(needle);
34
+ while (idx !== -1) {
35
+ count += 1;
36
+ idx = haystack.indexOf(needle, idx + needle.length);
37
+ }
38
+ return count;
39
+ }
40
+ function normalizeEol(text) {
41
+ return text.replace(/\r\n/g, "\n");
42
+ }
43
+ var schema = z.object({
44
+ path: z.string().describe("File path, absolute or relative to the working directory"),
45
+ old_string: z.string().min(1).describe("Exact text to replace"),
46
+ new_string: z.string().describe("Replacement text"),
47
+ replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
48
+ });
49
+ var editFileTool = {
50
+ name: "edit_file",
51
+ description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set. Refuses binary files and files over 5MB.",
52
+ permission: "write",
53
+ parameters: schema,
54
+ async execute(args, ctx) {
55
+ const filePath = path.resolve(ctx.cwd, args.path);
56
+ let content;
57
+ let readMtimeMs;
58
+ try {
59
+ const st = await stat(filePath);
60
+ if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
61
+ return {
62
+ content: `Refused to edit ${args.path}: file is ${st.size} bytes, over the 5MB limit (edits past it could not be restored by /undo)`,
63
+ isError: true
64
+ };
65
+ }
66
+ readMtimeMs = st.mtimeMs;
67
+ const buf = await readFile(filePath);
68
+ if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
69
+ return { content: `Refused to edit binary file: ${args.path}`, isError: true };
70
+ }
71
+ content = buf.toString("utf8");
72
+ } catch (err) {
73
+ return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
74
+ }
75
+ let working = content;
76
+ let oldString = args.old_string;
77
+ let newString = args.new_string;
78
+ let restoreEol = null;
79
+ let count = countOccurrences(working, oldString);
80
+ if (count === 0) {
81
+ const normalizedContent = normalizeEol(content);
82
+ const normalizedOld = normalizeEol(oldString);
83
+ const normalizedCount = countOccurrences(normalizedContent, normalizedOld);
84
+ if (normalizedCount > 0) {
85
+ const crlfCount = countOccurrences(content, "\r\n");
86
+ const loneLfCount = countOccurrences(content.replace(/\r\n/g, ""), "\n");
87
+ restoreEol = crlfCount > 0 && crlfCount >= loneLfCount ? (text) => text.replace(/\n/g, "\r\n") : null;
88
+ working = normalizedContent;
89
+ oldString = normalizedOld;
90
+ newString = normalizeEol(newString);
91
+ count = normalizedCount;
92
+ }
93
+ }
94
+ if (count === 0) {
95
+ return { content: `old_string not found in ${args.path}`, isError: true };
96
+ }
97
+ if (count > 1 && !args.replace_all) {
98
+ return {
99
+ content: `old_string matches ${count} locations in ${args.path}; provide more surrounding context or set replace_all`,
100
+ isError: true
101
+ };
102
+ }
103
+ let updated = args.replace_all ? working.split(oldString).join(newString) : working.replace(oldString, () => newString);
104
+ if (restoreEol) {
105
+ updated = restoreEol(updated);
106
+ }
107
+ try {
108
+ const now = await stat(filePath).catch(() => null);
109
+ if (!now || now.mtimeMs !== readMtimeMs) {
110
+ return {
111
+ content: `Failed to edit ${args.path}: file changed since it was read; re-read and retry`,
112
+ isError: true
113
+ };
114
+ }
115
+ await writeFile(filePath, updated, "utf8");
116
+ } catch (err) {
117
+ return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
118
+ }
119
+ const contentTooLarge = Buffer.byteLength(content, "utf8") > MAX_SNAPSHOT_CONTENT_BYTES;
120
+ await pushSnapshot({
121
+ id: nextSnapshotId(),
122
+ path: filePath,
123
+ existed: true,
124
+ content: contentTooLarge ? null : content,
125
+ contentTooLarge: contentTooLarge || void 0,
126
+ toolName: "edit_file",
127
+ timestamp: Date.now(),
128
+ turn: ctx.snapshotContext?.turn ?? currentTurnSeq(),
129
+ messageIndex: ctx.snapshotContext?.messageIndex ?? currentTurnMessageIndex(),
130
+ owner: ctx.snapshotContext?.owner ?? "root"
131
+ });
132
+ return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
133
+ }
134
+ };
135
+
136
+ // src/tools/fs/glob.ts
137
+ import path2 from "path";
138
+ import { z as z2 } from "zod";
139
+ var MAX_RESULTS = 100;
140
+ var GLOB_MAGIC = /[*?[{]/;
141
+ function literalDirPrefix(pattern) {
142
+ const segments = pattern.replace(/\\/g, "/").split("/").slice(0, -1);
143
+ const out = [];
144
+ for (const segment of segments) {
145
+ if (segment === "" || GLOB_MAGIC.test(segment)) {
146
+ break;
147
+ }
148
+ out.push(segment);
149
+ }
150
+ return out;
151
+ }
152
+ var schema2 = z2.object({
153
+ pattern: z2.string().describe(
154
+ "Glob pattern, e.g. 'src/**/*.ts'. Supports **, *, ?, {a,b} braces and [abc] character classes. A bare pattern like '*.ts' matches basenames recursively."
155
+ ),
156
+ path: z2.string().optional().describe("Directory to search, defaults to the working directory")
157
+ });
158
+ function hasParentSegment(pattern) {
159
+ return pattern.replace(/\\/g, "/").split("/").some((segment) => segment === "..");
160
+ }
161
+ var globTool = {
162
+ name: "glob",
163
+ description: "Find files by glob pattern. Skips node_modules, .git and dist. Follows symlinks that stay inside the working directory. Returns paths sorted by modification time, newest first, up to 100 results.",
164
+ permission: "read",
165
+ parameters: schema2,
166
+ async execute(args, ctx) {
167
+ if (hasParentSegment(args.pattern)) {
168
+ return {
169
+ content: `Refused glob pattern with '..' segment: ${args.pattern}`,
170
+ isError: true
171
+ };
172
+ }
173
+ const root = path2.resolve(ctx.cwd, args.path ?? ".");
174
+ const prefixSegments = literalDirPrefix(args.pattern);
175
+ const walkRoot = prefixSegments.length > 0 ? path2.join(root, ...prefixSegments) : root;
176
+ const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
177
+ const { files, aborted } = await walkFiles(walkRoot, void 0, {
178
+ ignore: createIgnorePredicate(ctx.cwd),
179
+ signal: ctx.abortSignal,
180
+ symlinkBoundary: ctx.cwd,
181
+ nestedIgnore: true
182
+ });
183
+ const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
184
+ if (matched.length === 0) {
185
+ return {
186
+ content: aborted ? `Search interrupted before any files matched pattern: ${args.pattern}` : `No files matched pattern: ${args.pattern}`
187
+ };
188
+ }
189
+ const lines = matched.map((f) => `${prefix}${f.rel}`);
190
+ if (aborted) {
191
+ lines.push("... (interrupted: partial results)");
192
+ }
193
+ return { content: lines.join("\n") };
194
+ }
195
+ };
196
+
197
+ // src/tools/fs/grep.ts
198
+ import { readFile as readFile2, realpath, stat as stat2 } from "fs/promises";
199
+ import path3 from "path";
200
+ import { z as z3 } from "zod";
201
+ var MAX_MATCHES = 250;
202
+ var MAX_LINE_CHARS = 500;
203
+ var MAX_PATTERN_CHARS = 200;
204
+ var MAX_FILE_BYTES = 5 * 1024 * 1024;
205
+ function isTooComplexPattern(pattern) {
206
+ return pattern.length > MAX_PATTERN_CHARS || /\)[+*]/.test(pattern) || /\)\{\d+(,\d*)?\}/.test(pattern);
207
+ }
208
+ var schema3 = z3.object({
209
+ pattern: z3.string().describe("Regular expression to search for"),
210
+ path: z3.string().optional().describe("File or directory to search, defaults to the working directory"),
211
+ glob: z3.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
212
+ ignoreCase: z3.boolean().optional().describe("Case-insensitive matching")
213
+ });
214
+ var grepTool = {
215
+ name: "grep",
216
+ description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, files over 5MB, sensitive files, node_modules and .git. Follows symlinks that stay inside the working directory.",
217
+ permission: "read",
218
+ parameters: schema3,
219
+ async execute(args, ctx) {
220
+ if (isTooComplexPattern(args.pattern)) {
221
+ return {
222
+ content: `pattern too complex: keep it under ${MAX_PATTERN_CHARS} characters and avoid quantified groups like (a+)+`,
223
+ isError: true
224
+ };
225
+ }
226
+ let re;
227
+ try {
228
+ re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
229
+ } catch (err) {
230
+ return { content: `Invalid regular expression: ${err.message}`, isError: true };
231
+ }
232
+ const root = path3.resolve(ctx.cwd, args.path ?? ".");
233
+ let files;
234
+ let interrupted = false;
235
+ try {
236
+ const st = await stat2(root);
237
+ if (st.isFile()) {
238
+ const resolved = await realpath(root).catch(() => null);
239
+ if (isSensitivePath(root) || resolved !== null && isSensitivePath(resolved)) {
240
+ return {
241
+ content: `Refused to grep sensitive file: ${args.path ?? "."}`,
242
+ isError: true
243
+ };
244
+ }
245
+ files = [{ abs: root, rel: path3.basename(root), mtimeMs: st.mtimeMs }];
246
+ } else {
247
+ const walked = await walkFiles(root, void 0, {
248
+ ignore: createIgnorePredicate(ctx.cwd),
249
+ withMtime: false,
250
+ signal: ctx.abortSignal,
251
+ symlinkBoundary: ctx.cwd,
252
+ nestedIgnore: true
253
+ });
254
+ interrupted = walked.aborted;
255
+ files = walked.files.filter(
256
+ (f) => !isSensitivePath(f.abs) && (f.resolved === void 0 || !isSensitivePath(f.resolved))
257
+ );
258
+ }
259
+ } catch {
260
+ return { content: `Path not found: ${args.path ?? "."}`, isError: true };
261
+ }
262
+ if (args.glob) {
263
+ const glob = args.glob;
264
+ files = files.filter((f) => matchesGlob(glob, f.rel));
265
+ }
266
+ const scanFile = async (file) => {
267
+ const st = await stat2(file.abs).catch(() => null);
268
+ if (!st) {
269
+ return null;
270
+ }
271
+ if (st.size > MAX_FILE_BYTES) {
272
+ skippedLarge += 1;
273
+ return null;
274
+ }
275
+ let buf;
276
+ try {
277
+ buf = await readFile2(file.abs);
278
+ } catch {
279
+ return null;
280
+ }
281
+ if (buf.includes(0)) {
282
+ return null;
283
+ }
284
+ const hits = [];
285
+ const lines = buf.toString("utf8").split("\n");
286
+ for (let i = 0; i < lines.length; i++) {
287
+ const line = lines[i] ?? "";
288
+ if (re.test(line)) {
289
+ const shown = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
290
+ hits.push(`${file.rel}:${i + 1}:${shown}`);
291
+ }
292
+ }
293
+ return hits;
294
+ };
295
+ const out = [];
296
+ let truncated = false;
297
+ let skippedLarge = 0;
298
+ outer: for (let i = 0; i < files.length; i += WALK_CONCURRENCY) {
299
+ if (ctx.abortSignal?.aborted) {
300
+ interrupted = true;
301
+ break;
302
+ }
303
+ const chunk = await Promise.all(
304
+ files.slice(i, i + WALK_CONCURRENCY).map((file) => scanFile(file))
305
+ );
306
+ for (const hits of chunk) {
307
+ if (!hits) {
308
+ continue;
309
+ }
310
+ for (const hit of hits) {
311
+ out.push(hit);
312
+ if (out.length >= MAX_MATCHES) {
313
+ truncated = true;
314
+ break outer;
315
+ }
316
+ }
317
+ }
318
+ }
319
+ const skippedNote = skippedLarge > 0 ? `(skipped ${skippedLarge} file(s) larger than 5MB)` : null;
320
+ if (out.length === 0) {
321
+ return {
322
+ content: `No matches for pattern: ${args.pattern}${skippedNote ? ` ${skippedNote}` : ""}${interrupted ? " (search interrupted)" : ""}`
323
+ };
324
+ }
325
+ if (truncated) {
326
+ out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
327
+ } else if (interrupted) {
328
+ out.push("... (interrupted: partial results)");
329
+ }
330
+ if (skippedNote) {
331
+ out.push(`... ${skippedNote}`);
332
+ }
333
+ return { content: out.join("\n") };
334
+ }
335
+ };
336
+
337
+ // src/tools/fs/read.ts
338
+ import { open, realpath as realpath2, stat as stat3 } from "fs/promises";
339
+ import path4 from "path";
340
+ import { z as z4 } from "zod";
341
+ var DEFAULT_LIMIT = 2e3;
342
+ var MAX_CHARS = 100 * 1024;
343
+ var MAX_READ_BYTES = 1024 * 1024;
344
+ var BINARY_PROBE_BYTES2 = 8192;
345
+ function decodeUtf16be(buf) {
346
+ const len = buf.length - buf.length % 2;
347
+ const swapped = Buffer.allocUnsafe(len);
348
+ for (let k = 0; k < len; k += 2) {
349
+ swapped[k] = buf[k + 1] ?? 0;
350
+ swapped[k + 1] = buf[k] ?? 0;
351
+ }
352
+ return swapped.toString("utf16le");
353
+ }
354
+ function decodeTextBuffer(buf) {
355
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
356
+ return { text: buf.subarray(2).toString("utf16le") };
357
+ }
358
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
359
+ return { text: decodeUtf16be(buf.subarray(2)) };
360
+ }
361
+ if (buf.subarray(0, BINARY_PROBE_BYTES2).includes(0)) {
362
+ return { binary: true };
363
+ }
364
+ return { text: buf.toString("utf8") };
365
+ }
366
+ var schema4 = z4.object({
367
+ path: z4.string().describe("File path, absolute or relative to the working directory"),
368
+ offset: z4.number().int().positive().optional().describe("1-based line number to start reading from"),
369
+ limit: z4.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
370
+ });
371
+ var READ_CACHE_MAX_ENTRIES = 200;
372
+ function createReadFileTool() {
373
+ const cache = /* @__PURE__ */ new Map();
374
+ return {
375
+ name: "read_file",
376
+ description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default. Re-reading an unchanged file range returns a short note instead of the content.",
377
+ permission: "read",
378
+ parameters: schema4,
379
+ reset: () => cache.clear(),
380
+ async execute(args, ctx) {
381
+ const filePath = path4.resolve(ctx.cwd, args.path);
382
+ const resolvedPath = await realpath2(filePath).catch(() => null);
383
+ if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
384
+ return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
385
+ }
386
+ const st = await stat3(filePath).catch(() => null);
387
+ if (!st) {
388
+ return { content: `File not found: ${args.path}`, isError: true };
389
+ }
390
+ if (st.isDirectory()) {
391
+ return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
392
+ }
393
+ const start = args.offset ?? 1;
394
+ const limit = args.limit ?? DEFAULT_LIMIT;
395
+ const cacheKey = resolvedPath ?? filePath;
396
+ const hit = cache.get(cacheKey);
397
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
398
+ const requestedEnd = Math.min(hit.totalLines, start - 1 + limit);
399
+ if (start >= hit.start && requestedEnd <= hit.end && start <= hit.totalLines) {
400
+ return {
401
+ content: `[unchanged since last read: ${args.path} \u2014 lines ${start}-${requestedEnd} of ${hit.totalLines} are already in context and the file has not changed]`
402
+ };
403
+ }
404
+ }
405
+ const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
406
+ const fileCapped = st.size > MAX_READ_BYTES;
407
+ const handle = await open(filePath, "r").catch(() => null);
408
+ if (!handle) {
409
+ return { content: `Failed to read ${args.path}`, isError: true };
410
+ }
411
+ let buf;
412
+ try {
413
+ const { bytesRead, buffer } = await handle.read(
414
+ Buffer.alloc(bytesToRead),
415
+ 0,
416
+ bytesToRead,
417
+ 0
418
+ );
419
+ buf = buffer.subarray(0, bytesRead);
420
+ } finally {
421
+ await handle.close().catch(() => {
422
+ });
423
+ }
424
+ const decoded = decodeTextBuffer(buf);
425
+ if ("binary" in decoded) {
426
+ return { content: `Cannot read binary file: ${args.path}`, isError: true };
427
+ }
428
+ const lines = decoded.text.split("\n");
429
+ const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
430
+ if (start > lines.length) {
431
+ return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
432
+ }
433
+ const end = Math.min(lines.length, start - 1 + limit);
434
+ const out = [];
435
+ let size = 0;
436
+ let truncatedBySize = false;
437
+ for (let i = start - 1; i < end; i++) {
438
+ const line = (lines[i] ?? "").replace(/\r$/, "");
439
+ const numbered = `${i + 1} ${line}`;
440
+ if (size + numbered.length > MAX_CHARS) {
441
+ truncatedBySize = true;
442
+ break;
443
+ }
444
+ out.push(numbered);
445
+ size += numbered.length;
446
+ }
447
+ const lastShown = start - 1 + out.length;
448
+ if (truncatedBySize) {
449
+ out.push(
450
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
451
+ );
452
+ } else if (end < lines.length || fileCapped) {
453
+ out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
454
+ }
455
+ if (out.length > 0) {
456
+ if (cache.size >= READ_CACHE_MAX_ENTRIES) cache.clear();
457
+ const prev = hit?.mtimeMs === st.mtimeMs && hit.size === st.size ? hit : void 0;
458
+ cache.set(cacheKey, {
459
+ mtimeMs: st.mtimeMs,
460
+ size: st.size,
461
+ totalLines: lines.length,
462
+ // Extend the remembered range when the new read overlaps or abuts
463
+ // the previous one; disjoint ranges replace it.
464
+ start: prev && start <= prev.end + 1 ? Math.min(prev.start, start) : start,
465
+ end: prev && lastShown >= prev.start - 1 ? Math.max(prev.end, lastShown) : lastShown
466
+ });
467
+ }
468
+ return { content: out.join("\n") };
469
+ }
470
+ };
471
+ }
472
+ var readFileTool = createReadFileTool();
473
+
474
+ // src/tools/fs/read-image.ts
475
+ import { readFile as readFile3, realpath as realpath3, stat as stat4 } from "fs/promises";
476
+ import path5 from "path";
477
+ import { z as z5 } from "zod";
478
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
479
+ var IMAGE_MIME_TYPES = {
480
+ ".png": "image/png",
481
+ ".jpg": "image/jpeg",
482
+ ".jpeg": "image/jpeg",
483
+ ".gif": "image/gif",
484
+ ".webp": "image/webp"
485
+ };
486
+ var schema5 = z5.object({
487
+ path: z5.string().describe(
488
+ "Image file path (png/jpg/jpeg/gif/webp), absolute or relative to the working directory"
489
+ )
490
+ });
491
+ var readImageTool = {
492
+ name: "read_image",
493
+ description: "Read an image file (png, jpg, gif, webp) and show it to the model. Use this whenever the user references a screenshot, diagram, or picture file you need to see. Images larger than 2000px are downsampled automatically.",
494
+ permission: "read",
495
+ parameters: schema5,
496
+ async execute(args, ctx) {
497
+ if (ctx.visionEnabled === false) {
498
+ return {
499
+ content: "The active model does not support image input (vision = false), so the image was not read. Work from text instead, or ask the user to describe the image.",
500
+ isError: true
501
+ };
502
+ }
503
+ const filePath = path5.resolve(ctx.cwd, args.path);
504
+ const resolvedPath = await realpath3(filePath).catch(() => null);
505
+ if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
506
+ return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
507
+ }
508
+ const mimeType = IMAGE_MIME_TYPES[path5.extname(filePath).toLowerCase()];
509
+ if (!mimeType) {
510
+ return {
511
+ content: `Not a supported image (png/jpg/jpeg/gif/webp): ${args.path}`,
512
+ isError: true
513
+ };
514
+ }
515
+ const st = await stat4(filePath).catch(() => null);
516
+ if (!st || !st.isFile()) {
517
+ return { content: `File not found: ${args.path}`, isError: true };
518
+ }
519
+ if (st.size > MAX_IMAGE_BYTES) {
520
+ return {
521
+ content: `Image too large (${Math.round(st.size / 1024)}KB > ${MAX_IMAGE_BYTES / 1024}KB): ${args.path}`,
522
+ isError: true
523
+ };
524
+ }
525
+ const buf = await readFile3(filePath).catch(() => null);
526
+ if (!buf || buf.length === 0) {
527
+ return { content: `Failed to read ${args.path}`, isError: true };
528
+ }
529
+ if (!probeImageDimensions(buf)) {
530
+ return {
531
+ content: `File is not a valid image (unrecognized header): ${args.path}`,
532
+ isError: true
533
+ };
534
+ }
535
+ const downsampled = await downsampleImageIfNeeded({
536
+ path: args.path,
537
+ mimeType,
538
+ data: buf.toString("base64")
539
+ });
540
+ if (downsampled.oversized && !downsampled.resized) {
541
+ return {
542
+ content: `Image exceeds 2000px and could not be downsampled on this platform: ${args.path}`,
543
+ isError: true
544
+ };
545
+ }
546
+ const image = downsampled.image;
547
+ const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
548
+ const sizeKB = Math.round(image.data.length / 1024);
549
+ const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
550
+ const resizedNote = downsampled.resized ? " (downsampled to fit 2000px)" : "";
551
+ return {
552
+ content: `Image loaded: ${args.path} (${desc}, ~${sizeKB}KB)${resizedNote}. The image is attached below \u2014 look at it now.`,
553
+ images: [image]
554
+ };
555
+ }
556
+ };
557
+
558
+ // src/tools/fs/write.ts
559
+ import { mkdir, writeFile as writeFile2 } from "fs/promises";
560
+ import path6 from "path";
561
+ import { z as z6 } from "zod";
562
+ var schema6 = z6.object({
563
+ path: z6.string().describe("File path, absolute or relative to the working directory"),
564
+ content: z6.string().describe("Full content to write to the file")
565
+ });
566
+ var writeFileTool = {
567
+ name: "write_file",
568
+ description: "Write content to a file, creating parent directories as needed. Overwrites existing files. Refuses content with NUL bytes (binary) or over 5MB.",
569
+ permission: "write",
570
+ parameters: schema6,
571
+ async execute(args, ctx) {
572
+ const filePath = path6.resolve(ctx.cwd, args.path);
573
+ if (args.content.includes("\0")) {
574
+ return {
575
+ content: `Refused to write ${args.path}: content contains NUL bytes (write_file is text-only)`,
576
+ isError: true
577
+ };
578
+ }
579
+ const bytes = Buffer.byteLength(args.content, "utf8");
580
+ if (bytes > MAX_SNAPSHOT_CONTENT_BYTES) {
581
+ return {
582
+ content: `Refused to write ${args.path}: content is ${bytes} bytes, over the 5MB limit (writes past it could not be restored by /undo)`,
583
+ isError: true
584
+ };
585
+ }
586
+ const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
587
+ try {
588
+ await mkdir(path6.dirname(filePath), { recursive: true });
589
+ await writeFile2(filePath, args.content, "utf8");
590
+ } catch (err) {
591
+ return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
592
+ }
593
+ await pushSnapshot(snapshot);
594
+ return { content: `Wrote ${bytes} bytes to ${args.path}` };
595
+ }
596
+ };
597
+
598
+ // src/tools/screenshot.ts
599
+ import { z as z7 } from "zod";
600
+
601
+ // src/core/screenshot.ts
602
+ import { execFile } from "child_process";
603
+ import { randomUUID } from "crypto";
604
+ import { readFile as readFile4, unlink } from "fs/promises";
605
+ import os from "os";
606
+ import path7 from "path";
607
+ import { promisify } from "util";
608
+ var SCREENSHOT_TIMEOUT_MS = 2e4;
609
+ var execFileAsync = promisify(execFile);
610
+ function windowsScreenshotScript(outputFile) {
611
+ const output = outputFile.replace(/'/g, "''");
612
+ return [
613
+ `try { Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(int v);' -Name Dpi -Namespace StarShot -ErrorAction Stop; [void][StarShot.Dpi]::SetProcessDpiAwarenessContext(-4) } catch { }`,
614
+ "Add-Type -AssemblyName System.Windows.Forms,System.Drawing",
615
+ "$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds",
616
+ "$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height)",
617
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
618
+ "$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)",
619
+ `$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
620
+ "$g.Dispose(); $bmp.Dispose()"
621
+ ].join("; ");
622
+ }
623
+ function screenshotPlan(platform, outputFile, env = process.env, tools = {}) {
624
+ if (platform === "win32") {
625
+ return {
626
+ command: "powershell.exe",
627
+ args: ["-NoProfile", "-NonInteractive", "-Command", windowsScreenshotScript(outputFile)]
628
+ };
629
+ }
630
+ if (platform === "darwin") {
631
+ return { command: "screencapture", args: ["-x", "-D", "1", outputFile] };
632
+ }
633
+ if (platform === "linux") {
634
+ if (env.WAYLAND_DISPLAY) {
635
+ if (tools.grim) return { command: "grim", args: [outputFile] };
636
+ if (tools["gnome-screenshot"]) {
637
+ return { command: "gnome-screenshot", args: ["-f", outputFile] };
638
+ }
639
+ if (tools.spectacle) {
640
+ return { command: "spectacle", args: ["-b", "-n", "-o", outputFile] };
641
+ }
642
+ return null;
643
+ }
644
+ if (env.DISPLAY) {
645
+ if (tools.import) return { command: "import", args: ["-window", "root", outputFile] };
646
+ if (tools.scrot) return { command: "scrot", args: ["--overwrite", outputFile] };
647
+ if (tools["gnome-screenshot"]) {
648
+ return { command: "gnome-screenshot", args: ["-f", outputFile] };
649
+ }
650
+ if (tools.spectacle) {
651
+ return { command: "spectacle", args: ["-b", "-n", "-o", outputFile] };
652
+ }
653
+ return null;
654
+ }
655
+ return null;
656
+ }
657
+ return null;
658
+ }
659
+ function noScreenshotToolMessage(platform, env = process.env) {
660
+ if (platform === "linux") {
661
+ if (!env.WAYLAND_DISPLAY && !env.DISPLAY) {
662
+ return "no display server (neither DISPLAY nor WAYLAND_DISPLAY is set) \u2014 this session is headless (e.g. SSH), there is no screen to capture";
663
+ }
664
+ return "no screenshot tool found \u2014 install one of: grim or spectacle (Wayland), scrot or ImageMagick's import (X11), or gnome-screenshot";
665
+ }
666
+ return `screenshots are not supported on this platform (${platform})`;
667
+ }
668
+ async function commandExists(command) {
669
+ const checker = process.platform === "win32" ? "where" : "which";
670
+ try {
671
+ await execFileAsync(checker, [command], { timeout: SCREENSHOT_TIMEOUT_MS });
672
+ return true;
673
+ } catch {
674
+ return false;
675
+ }
676
+ }
677
+ var LINUX_TOOL_NAMES = ["grim", "gnome-screenshot", "spectacle", "import", "scrot"];
678
+ async function captureScreenshot() {
679
+ const outputFile = path7.join(os.tmpdir(), `star-screenshot-${randomUUID()}.png`);
680
+ try {
681
+ const tools = {};
682
+ if (process.platform === "linux") {
683
+ const found = await Promise.all(LINUX_TOOL_NAMES.map(commandExists));
684
+ for (const [i, name] of LINUX_TOOL_NAMES.entries()) {
685
+ tools[name] = found[i] ?? false;
686
+ }
687
+ }
688
+ const plan = screenshotPlan(process.platform, outputFile, process.env, tools);
689
+ if (!plan) {
690
+ return { ok: false, error: noScreenshotToolMessage(process.platform) };
691
+ }
692
+ try {
693
+ await execFileAsync(plan.command, plan.args, { timeout: SCREENSHOT_TIMEOUT_MS });
694
+ } catch (err) {
695
+ const detail = err instanceof Error && err.message ? ` (${err.message})` : "";
696
+ return { ok: false, error: `${plan.command} failed to capture the screen${detail}` };
697
+ }
698
+ const buf = await readFile4(outputFile).catch(() => null);
699
+ if (!buf || buf.length === 0) {
700
+ return { ok: false, error: `${plan.command} produced no image file` };
701
+ }
702
+ if (!probeImageDimensions(buf)) {
703
+ return { ok: false, error: `${plan.command} produced an unrecognized image file` };
704
+ }
705
+ const downsampled = await downsampleImageIfNeeded({
706
+ path: "screen",
707
+ mimeType: "image/png",
708
+ data: buf.toString("base64")
709
+ });
710
+ if (downsampled.oversized && !downsampled.resized) {
711
+ return {
712
+ ok: false,
713
+ error: "screenshot exceeds 2000px and could not be downsampled on this platform"
714
+ };
715
+ }
716
+ return { ok: true, image: downsampled.image };
717
+ } finally {
718
+ await unlink(outputFile).catch(() => {
719
+ });
720
+ }
721
+ }
722
+
723
+ // src/tools/screenshot.ts
724
+ var schema7 = z7.object({});
725
+ function createScreenshotTool(capture = captureScreenshot) {
726
+ return {
727
+ name: "screenshot",
728
+ description: "Capture a screenshot of the primary display and see it. Use this to check the actual visual result of a running program (web page, GUI) \u2014 e.g. after starting a dev server \u2014 instead of inferring from code. The capture shows exactly what is visible on screen, including overlapping windows: content hidden behind other windows cannot be seen, so if a specific window matters ask the user to bring it to the front first. Note the capture may include anything else currently on screen.",
729
+ permission: "read",
730
+ parameters: schema7,
731
+ async execute(_args, ctx) {
732
+ if (ctx.visionEnabled === false) {
733
+ return {
734
+ content: "The active model does not support image input (vision = false), so no screenshot was taken. Work from text instead, or ask the user to describe what is on screen.",
735
+ isError: true
736
+ };
737
+ }
738
+ const result = await capture();
739
+ if (!result.ok) {
740
+ return { content: `Screenshot failed: ${result.error}`, isError: true };
741
+ }
742
+ const image = result.image;
743
+ const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
744
+ const sizeKB = Math.round(image.data.length / 1024);
745
+ const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
746
+ return {
747
+ content: `Screenshot captured (${desc}, ~${sizeKB}KB). The image is attached below \u2014 look at it now.`,
748
+ images: [image]
749
+ };
750
+ }
751
+ };
752
+ }
753
+ var screenshotTool = createScreenshotTool();
754
+
755
+ // src/tools/tasks.ts
756
+ import { z as z8 } from "zod";
757
+
758
+ // src/tasks/format.ts
759
+ function formatDuration(startedAt, endedAt) {
760
+ const seconds = Math.max(0, Math.round(((endedAt ?? Date.now()) - startedAt) / 1e3));
761
+ if (seconds < 60) return `${seconds}s`;
762
+ const minutes = Math.floor(seconds / 60);
763
+ if (minutes < 60) return `${minutes}m${seconds % 60}s`;
764
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
765
+ }
766
+ function formatStatus(task) {
767
+ const duration = formatDuration(task.startedAt, task.endedAt);
768
+ if (task.status === "running") return `running ${duration}`;
769
+ const exit = task.exitCode === null || task.exitCode === void 0 ? "" : `, exit ${task.exitCode}`;
770
+ return `${task.status}${exit} after ${duration}`;
771
+ }
772
+ function formatTaskLine(task) {
773
+ const label = task.description ? `${task.description} (${task.command})` : task.command;
774
+ return `${task.id} [${formatStatus(task)}] ${label}`;
775
+ }
776
+ function formatTaskList(tasks) {
777
+ if (tasks.length === 0) return "No background tasks.";
778
+ return `Background tasks:
779
+ ${tasks.map(formatTaskLine).join("\n")}`;
780
+ }
781
+ function formatTaskStarted(task) {
782
+ const label = task.description ? `${task.description} (${task.command})` : task.command;
783
+ return `Background task ${task.id} started: ${label}`;
784
+ }
785
+ function formatTaskFinished(task) {
786
+ const exit = task.exitCode === null || task.exitCode === void 0 ? "" : ` (exit ${task.exitCode})`;
787
+ switch (task.status) {
788
+ case "completed":
789
+ return `Background task ${task.id} finished${exit}: ${task.command}`;
790
+ case "failed":
791
+ return `Background task ${task.id} failed${exit}: ${task.command}`;
792
+ case "timed_out":
793
+ return `Background task ${task.id} timed out: ${task.command}`;
794
+ case "stopped":
795
+ return `Background task ${task.id} stopped: ${task.command}`;
796
+ default:
797
+ return `Background task ${task.id}: ${task.command}`;
798
+ }
799
+ }
800
+
801
+ // src/tools/tasks.ts
802
+ var idSchema = z8.object({
803
+ id: z8.string().describe("Task id, e.g. task-1 or agent-1")
804
+ });
805
+ function truncateOutput(s) {
806
+ if (s.length <= MAX_OUTPUT) return s;
807
+ return `[... earlier output truncated; showing the last ${MAX_OUTPUT} characters ...]
808
+ ${s.slice(-MAX_OUTPUT)}`;
809
+ }
810
+ function callerId(ctx) {
811
+ return ctx.agentId ?? "root";
812
+ }
813
+ function accessibleShellTask(ctx, id) {
814
+ const task = defaultTaskManager.get(id);
815
+ if (!task) return void 0;
816
+ const caller = callerId(ctx);
817
+ return caller === "root" || task.ownerId === caller ? task : void 0;
818
+ }
819
+ function formatAgentTaskLine(task) {
820
+ const duration = formatDuration(task.startedAt, task.endedAt);
821
+ const label = task.description ?? task.prompt.slice(0, 60);
822
+ const status = task.status === "running" ? `running ${duration}` : `${task.status} after ${duration}`;
823
+ return `${task.id} [${status}] ${label}`;
824
+ }
825
+ var taskListTool = {
826
+ name: "task_list",
827
+ description: "List background tasks (shell commands and subagents) with status and runtime.",
828
+ permission: "read",
829
+ parameters: z8.object({}),
830
+ execute(_args, ctx) {
831
+ const caller = callerId(ctx);
832
+ const shell = formatTaskList(
833
+ defaultTaskManager.list().filter((t) => caller === "root" || t.ownerId === caller)
834
+ );
835
+ const agents = caller === "root" ? defaultAgentTasks.list() : [];
836
+ const agentLines = agents.length === 0 ? "No background subagents." : `Background subagents:
837
+ ${agents.map(formatAgentTaskLine).join("\n")}`;
838
+ return Promise.resolve({ content: `${shell}
839
+ ${agentLines}` });
840
+ }
841
+ };
842
+ var taskOutputTool = {
843
+ name: "task_output",
844
+ description: "Read the current output of a background task (shell command or subagent; works while still running).",
845
+ permission: "read",
846
+ parameters: idSchema,
847
+ execute(args, ctx) {
848
+ const shell = accessibleShellTask(ctx, args.id);
849
+ if (shell) {
850
+ const body = truncateOutput(shell.output.replace(/\s+$/, "") || "(no output yet)");
851
+ return Promise.resolve({ content: `${formatTaskLine(shell)}
852
+ ${body}` });
853
+ }
854
+ const agent = callerId(ctx) === "root" ? defaultAgentTasks.get(args.id) : void 0;
855
+ if (agent) {
856
+ const body = agent.status === "running" ? "(still running)" : truncateOutput(agent.result.replace(/\s+$/, "") || "(no report)");
857
+ return Promise.resolve({ content: `${formatAgentTaskLine(agent)}
858
+ ${body}` });
859
+ }
860
+ return Promise.resolve({ content: `Unknown task: ${args.id}`, isError: true });
861
+ }
862
+ };
863
+ var taskKillTool = {
864
+ name: "task_kill",
865
+ description: "Stop a running background task (shell command process tree or subagent).",
866
+ permission: "exec",
867
+ parameters: idSchema,
868
+ execute(args, ctx) {
869
+ const shell = accessibleShellTask(ctx, args.id);
870
+ const agent = shell || callerId(ctx) !== "root" ? void 0 : defaultAgentTasks.get(args.id);
871
+ const task = shell ?? agent;
872
+ if (!task) {
873
+ return Promise.resolve({ content: `Unknown task: ${args.id}`, isError: true });
874
+ }
875
+ if (task.status !== "running") {
876
+ return Promise.resolve({
877
+ content: `Task ${args.id} is not running (status: ${task.status}).`,
878
+ isError: true
879
+ });
880
+ }
881
+ if (shell) {
882
+ defaultTaskManager.kill(args.id);
883
+ } else {
884
+ defaultAgentTasks.kill(args.id);
885
+ }
886
+ return Promise.resolve({ content: `Task ${args.id} stopped.` });
887
+ }
888
+ };
889
+
890
+ // src/tools/web/search.ts
891
+ import { z as z9 } from "zod";
892
+
893
+ // src/llm/debug.ts
894
+ import { appendFileSync } from "fs";
895
+ import path8 from "path";
896
+ function debugStreamLog(event, data) {
897
+ if (!process.env.STAR_DEBUG) return;
898
+ try {
899
+ const line = `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data })}
900
+ `;
901
+ appendFileSync(path8.join(starHome(), "debug.log"), line);
902
+ } catch {
903
+ }
904
+ }
905
+
906
+ // src/tools/web/search.ts
907
+ var TIMEOUT_MS = 15e3;
908
+ var DEFAULT_MAX_RESULTS = 5;
909
+ var MAX_RESULTS_LIMIT = 10;
910
+ var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
911
+ var schema8 = z9.object({
912
+ query: z9.string().min(1).describe("The search query"),
913
+ maxResults: z9.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
914
+ `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
915
+ )
916
+ });
917
+ function stripTags(s) {
918
+ return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
919
+ }
920
+ function normalizeUrl(href) {
921
+ const decoded = decodeEntities(href);
922
+ const uddg = decoded.match(/[?&]uddg=([^&]+)/);
923
+ if (uddg?.[1]) {
924
+ try {
925
+ return decodeURIComponent(uddg[1]);
926
+ } catch {
927
+ return decoded;
928
+ }
929
+ }
930
+ if (decoded.startsWith("//")) {
931
+ return `https:${decoded}`;
932
+ }
933
+ return decoded;
934
+ }
935
+ function hrefOf(tag) {
936
+ const match = tag.match(/href\s*=\s*"([^"]*)"/i) ?? tag.match(/href\s*=\s*'([^']*)'/i);
937
+ return match?.[1];
938
+ }
939
+ function parseResults(html) {
940
+ const results = [];
941
+ const anchorRe = /<a\b[^>]*\bclass\s*=\s*"[^"]*\bresult__a\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi;
942
+ const anchors = [...html.matchAll(anchorRe)];
943
+ for (const [i, anchor] of anchors.entries()) {
944
+ const full = anchor[0];
945
+ const href = hrefOf(full);
946
+ if (!href) {
947
+ continue;
948
+ }
949
+ const url = normalizeUrl(href);
950
+ const title = stripTags(full.replace(/^<a\b[^>]*>/i, "").replace(/<\/a>\s*$/i, ""));
951
+ const blockStart = (anchor.index ?? 0) + full.length;
952
+ const blockEnd = i + 1 < anchors.length ? anchors[i + 1]?.index ?? html.length : html.length;
953
+ const block = html.slice(blockStart, blockEnd);
954
+ const snippetMatch = block.match(
955
+ /<(?:a|div)\b[^>]*\bclass\s*=\s*"[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div)>/i
956
+ );
957
+ const snippet = snippetMatch?.[1] ? stripTags(snippetMatch[1]) : "";
958
+ if (title && url) {
959
+ results.push({ title, url, snippet });
960
+ }
961
+ }
962
+ return results;
963
+ }
964
+ function parseDdgLiteResults(html) {
965
+ const results = [];
966
+ const anchorRe = /<a\b[^>]*\bclass\s*=\s*["'][^"']*\bresult-link\b[^"']*["'][^>]*>[\s\S]*?<\/a>/gi;
967
+ const anchors = [...html.matchAll(anchorRe)];
968
+ for (const [i, anchor] of anchors.entries()) {
969
+ const full = anchor[0];
970
+ const href = hrefOf(full);
971
+ if (!href) {
972
+ continue;
973
+ }
974
+ const url = normalizeUrl(href);
975
+ const title = stripTags(full.replace(/^<a\b[^>]*>/i, "").replace(/<\/a>\s*$/i, ""));
976
+ const blockStart = (anchor.index ?? 0) + full.length;
977
+ const blockEnd = i + 1 < anchors.length ? anchors[i + 1]?.index ?? html.length : html.length;
978
+ const block = html.slice(blockStart, blockEnd);
979
+ const snippetMatch = block.match(
980
+ /<td\b[^>]*\bclass\s*=\s*["'][^"']*\bresult-snippet\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i
981
+ );
982
+ const snippet = snippetMatch?.[1] ? stripTags(snippetMatch[1]) : "";
983
+ if (title && url) {
984
+ results.push({ title, url, snippet });
985
+ }
986
+ }
987
+ return results;
988
+ }
989
+ function parseBingResults(html) {
990
+ const results = [];
991
+ const blockRe = /<li\b[^>]*\bclass\s*=\s*"[^"]*\bb_algo\b[^"]*"[^>]*>/gi;
992
+ const blocks = [...html.matchAll(blockRe)];
993
+ for (const [i, block] of blocks.entries()) {
994
+ const start = block.index ?? 0;
995
+ const end = i + 1 < blocks.length ? blocks[i + 1]?.index ?? html.length : html.length;
996
+ const slice = html.slice(start, end);
997
+ const headMatch = slice.match(
998
+ /<h2\b[^>]*>[\s\S]*?<a\b[^>]*?\shref\s*=\s*"([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>/i
999
+ );
1000
+ if (!headMatch) {
1001
+ continue;
1002
+ }
1003
+ const url = decodeEntities(headMatch[1] ?? "").trim();
1004
+ const title = stripTags(headMatch[2] ?? "");
1005
+ const snippetMatch = slice.match(/<p\b[^>]*>([\s\S]*?)<\/p>/i);
1006
+ const snippet = snippetMatch?.[1] ? stripTags(snippetMatch[1]) : "";
1007
+ if (title && /^https?:\/\//.test(url)) {
1008
+ results.push({ title, url, snippet });
1009
+ }
1010
+ }
1011
+ return results;
1012
+ }
1013
+ var SEARCH_SOURCES = [
1014
+ {
1015
+ name: "duckduckgo-html",
1016
+ buildUrl: (query) => `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
1017
+ parse: parseResults
1018
+ },
1019
+ {
1020
+ name: "duckduckgo-lite",
1021
+ buildUrl: (query) => `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`,
1022
+ parse: parseDdgLiteResults
1023
+ },
1024
+ {
1025
+ name: "bing",
1026
+ buildUrl: (query) => `https://www.bing.com/search?q=${encodeURIComponent(query)}`,
1027
+ parse: parseBingResults
1028
+ }
1029
+ ];
1030
+ var webSearchTool = {
1031
+ name: "web_search",
1032
+ description: "Search the web and return a numbered list of results with title, URL and snippet. Uses DuckDuckGo with automatic fallback to DuckDuckGo Lite and Bing when a source fails or returns nothing. No API key required. Use web_fetch to read a result's full page.",
1033
+ permission: "read",
1034
+ parameters: schema8,
1035
+ async execute(args, ctx) {
1036
+ const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
1037
+ const failures = [];
1038
+ for (const source of SEARCH_SOURCES) {
1039
+ const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS);
1040
+ const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
1041
+ let res;
1042
+ try {
1043
+ res = await fetch(source.buildUrl(args.query), {
1044
+ signal,
1045
+ redirect: "follow",
1046
+ headers: { "user-agent": USER_AGENT, accept: "text/html" }
1047
+ });
1048
+ } catch (err) {
1049
+ if (ctx.abortSignal?.aborted) {
1050
+ return { content: "ERROR: request aborted", isError: true };
1051
+ }
1052
+ const name = err instanceof Error ? err.name : "";
1053
+ if (timeoutSignal.aborted || name === "TimeoutError") {
1054
+ failures.push(`${source.name}: search timed out after ${TIMEOUT_MS / 1e3}s`);
1055
+ } else {
1056
+ const message = err instanceof Error ? err.message : String(err);
1057
+ failures.push(`${source.name}: search request failed: ${message}`);
1058
+ }
1059
+ continue;
1060
+ }
1061
+ if (!res.ok) {
1062
+ await res.body?.cancel().catch(() => {
1063
+ });
1064
+ failures.push(`${source.name}: HTTP ${res.status} ${res.statusText}`);
1065
+ continue;
1066
+ }
1067
+ let html;
1068
+ try {
1069
+ html = await res.text();
1070
+ } catch (err) {
1071
+ const message = err instanceof Error ? err.message : String(err);
1072
+ failures.push(`${source.name}: failed to read search response: ${message}`);
1073
+ continue;
1074
+ }
1075
+ const results = source.parse(html).slice(0, maxResults);
1076
+ if (results.length === 0) {
1077
+ failures.push(
1078
+ `${source.name}: no results found (the response may have been blocked or the page layout changed)`
1079
+ );
1080
+ continue;
1081
+ }
1082
+ debugStreamLog("web_search", {
1083
+ source: source.name,
1084
+ query: args.query,
1085
+ results: results.length,
1086
+ failures
1087
+ });
1088
+ const lines = results.map(
1089
+ (r, i) => `${i + 1}. ${r.title}
1090
+ ${r.url}${r.snippet ? `
1091
+ ${r.snippet}` : ""}`
1092
+ );
1093
+ return { content: lines.join("\n") };
1094
+ }
1095
+ debugStreamLog("web_search_failed", { query: args.query, failures });
1096
+ return {
1097
+ content: `ERROR: web search failed for "${args.query}" \u2014 ${failures.join("; ")}`,
1098
+ isError: true
1099
+ };
1100
+ }
1101
+ };
1102
+
1103
+ // src/tools/registry.ts
1104
+ var ToolRegistry = class {
1105
+ tools = /* @__PURE__ */ new Map();
1106
+ // todoStore defaults to the module-level shared store; subagent loops pass
1107
+ // their own instance so a child's todo_write cannot clobber the parent list.
1108
+ constructor(todoStore) {
1109
+ for (const tool of [
1110
+ // Fresh per registry (i.e. per agent loop): the unchanged-since-last-
1111
+ // read cache must not leak across loops, whose contexts differ.
1112
+ createReadFileTool(),
1113
+ readImageTool,
1114
+ screenshotTool,
1115
+ writeFileTool,
1116
+ editFileTool,
1117
+ globTool,
1118
+ grepTool,
1119
+ bashTool,
1120
+ webFetchTool,
1121
+ webSearchTool,
1122
+ taskListTool,
1123
+ taskOutputTool,
1124
+ taskKillTool,
1125
+ ...createTodoTools(todoStore)
1126
+ ]) {
1127
+ this.register(tool);
1128
+ }
1129
+ }
1130
+ register(tool) {
1131
+ this.tools.set(tool.name, tool);
1132
+ }
1133
+ // Drops per-session volatile tool state after a wholesale history rewrite
1134
+ // (compaction, /undo, resume): anything a tool remembers as "already sent
1135
+ // to the model" may no longer be in the context.
1136
+ resetVolatileState() {
1137
+ for (const tool of this.tools.values()) tool.reset?.();
1138
+ }
1139
+ get(name) {
1140
+ return this.tools.get(name);
1141
+ }
1142
+ list() {
1143
+ return [...this.tools.values()];
1144
+ }
1145
+ names() {
1146
+ return [...this.tools.keys()];
1147
+ }
1148
+ };
1149
+
1150
+ // src/tools/index.ts
1151
+ function createDefaultRegistry(todoStore) {
1152
+ return new ToolRegistry(todoStore);
1153
+ }
1154
+
1155
+ export {
1156
+ debugStreamLog,
1157
+ formatTaskList,
1158
+ formatTaskStarted,
1159
+ formatTaskFinished,
1160
+ ToolRegistry,
1161
+ createDefaultRegistry
1162
+ };
1163
+ //# sourceMappingURL=chunk-OIJYNQFZ.js.map