xiaotime 0.6.0 → 0.7.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.
package/dist/todos.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Task/todo tracking — SPRINT-CLI-CAPABILITY-PARITY-1 (#14608).
3
+ *
4
+ * A working-memory aid for multi-step work: the agent writes the whole
5
+ * task list each time (the Claude-Code TodoWrite shape — the list is
6
+ * state the model owns, not an append log), and the rendered checklist
7
+ * comes back as the tool result so it lands in the transcript for both
8
+ * the classic and Ink surfaces without either needing bespoke render
9
+ * wiring. Kept in a session-global store so a future status bar can read
10
+ * the current list.
11
+ */
12
+ const VALID = new Set(["pending", "in_progress", "completed"]);
13
+ let todos = [];
14
+ /**
15
+ * Replace the whole list. Throws on a malformed entry so the tool
16
+ * surfaces a real error instead of silently storing junk.
17
+ */
18
+ export function setTodos(list) {
19
+ if (!Array.isArray(list)) {
20
+ throw new Error("todos must be an array of { content, status }");
21
+ }
22
+ const next = list.map((raw, i) => {
23
+ const item = raw;
24
+ const content = typeof item?.content === "string" ? item.content.trim() : "";
25
+ const status = String(item?.status ?? "");
26
+ if (!content)
27
+ throw new Error(`todo #${i + 1} is missing content`);
28
+ if (!VALID.has(status)) {
29
+ throw new Error(`todo #${i + 1} has invalid status "${status}" (use pending / in_progress / completed)`);
30
+ }
31
+ return { content, status: status };
32
+ });
33
+ todos = next;
34
+ return todos;
35
+ }
36
+ export function getTodos() {
37
+ return todos;
38
+ }
39
+ export function clearTodos() {
40
+ todos = [];
41
+ }
42
+ const MARK = {
43
+ completed: "[x]",
44
+ in_progress: "[~]",
45
+ pending: "[ ]",
46
+ };
47
+ /** Render the list as a plain-text checklist (tool-result body). */
48
+ export function renderTodos(list = todos) {
49
+ if (list.length === 0)
50
+ return "(no tasks)";
51
+ const done = list.filter((t) => t.status === "completed").length;
52
+ const lines = list.map((t) => `${MARK[t.status]} ${t.content}`);
53
+ return `Tasks (${done}/${list.length} done):\n${lines.join("\n")}`;
54
+ }
55
+ //# sourceMappingURL=todos.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"todos.js","sourceRoot":"","sources":["../src/todos.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AASH,MAAM,KAAK,GAAwB,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,aAAa,EAAE,WAAW,CAAC,CAAC,CAAC;AAEpF,IAAI,KAAK,GAAW,EAAE,CAAC;AAEvB;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAa;IACpC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,+CAA+C,CAAC,CAAC;IACnE,CAAC;IACD,MAAM,IAAI,GAAW,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE;QACvC,MAAM,IAAI,GAAG,GAA8C,CAAC;QAC5D,MAAM,OAAO,GAAG,OAAO,IAAI,EAAE,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7E,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;QACnE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,wBAAwB,MAAM,2CAA2C,CAAC,CAAC;QAC3G,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAoB,EAAE,CAAC;IACnD,CAAC,CAAC,CAAC;IACH,KAAK,GAAG,IAAI,CAAC;IACb,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,QAAQ;IACtB,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,KAAK,GAAG,EAAE,CAAC;AACb,CAAC;AAED,MAAM,IAAI,GAA+B;IACvC,SAAS,EAAE,KAAK;IAChB,WAAW,EAAE,KAAK;IAClB,OAAO,EAAE,KAAK;CACf,CAAC;AAEF,oEAAoE;AACpE,MAAM,UAAU,WAAW,CAAC,OAAe,KAAK;IAC9C,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,YAAY,CAAC;IAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,WAAW,CAAC,CAAC,MAAM,CAAC;IACjE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;IAChE,OAAO,UAAU,IAAI,IAAI,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AACrE,CAAC"}
package/dist/tools.d.ts CHANGED
@@ -47,4 +47,30 @@ export declare function executeTool(call: ToolCall, ui: SessionUI): Promise<Tool
47
47
  export declare function listRecursive(dir: string, pattern?: string, cap?: number): string[];
48
48
  export declare function _listDirectoryMax(): number;
49
49
  export declare function globToRegex(glob: string): RegExp;
50
+ /** Count non-overlapping occurrences of `needle` in `hay`. */
51
+ export declare function _countOccurrences(hay: string, needle: string): number;
52
+ /**
53
+ * Replace the first literal occurrence of `needle` with `repl`.
54
+ * Uses index-splicing (not String.replace) so `$&`/`$1` sequences in
55
+ * `repl` are inserted verbatim rather than interpreted as regex
56
+ * back-references (#14608 — the surgical-edit correctness trap).
57
+ */
58
+ export declare function _replaceFirst(hay: string, needle: string, repl: string): string;
59
+ /**
60
+ * Recursively grep a directory tree. Skips dotfiles, node_modules,
61
+ * binary files (any NUL byte), and files larger than 2 MB. Returns
62
+ * `relpath:lineno:linetext` matches, capped at `cap`. Directory entries
63
+ * are visited in sorted order for deterministic output.
64
+ */
65
+ export declare function grepTree(dir: string, regex: RegExp, glob?: string, cap?: number): {
66
+ matches: string[];
67
+ truncated: boolean;
68
+ };
69
+ /**
70
+ * Reduce an HTML document to readable text: drop script/style/comments
71
+ * and tags, decode the common named entities, and collapse whitespace.
72
+ * Intentionally minimal — enough to make a fetched page legible to the
73
+ * agent, not a full HTML parser.
74
+ */
75
+ export declare function stripHtml(html: string): string;
50
76
  //# sourceMappingURL=tools.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAOH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAS/C,MAAM,WAAW,QAAQ;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiBD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE;QACZ,IAAI,EAAE,QAAQ,CAAC;QACf,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC,CAAC;QACrF,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;CACH;AAED,eAAO,MAAM,aAAa,EAAE,iBAAiB,EAiF5C,CAAC;AAIF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,QAExC;AACD,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE7C;AAkED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,QAAQ,EACd,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,UAAU,CAAC,CAkMrB;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAyBnF;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAO1C;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOhD"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAOH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAa/C,MAAM,WAAW,QAAQ;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiBD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE;QACZ,IAAI,EAAE,QAAQ,CAAC;QACf,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC,CAAC;QACrF,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;CACH;AAED,eAAO,MAAM,aAAa,EAAE,iBAAiB,EA+N5C,CAAC;AAIF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,QAExC;AACD,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE7C;AAkED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,QAAQ,EACd,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,UAAU,CAAC,CAoerB;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAyBnF;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAO1C;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOhD;AAED,8DAA8D;AAC9D,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CASrE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAI/E;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CACtB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,EACb,GAAG,SAAM,GACR;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAsD3C;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAe9C"}
package/dist/tools.js CHANGED
@@ -20,7 +20,10 @@ import * as path from "path";
20
20
  import * as child_process from "child_process";
21
21
  import chalk from "chalk";
22
22
  import { printFileDiff, printApprovalPrompt } from "./display.js";
23
- import { loadAllowlist, matchAllowlist, appendAllowlist, derivePattern, } from "./settings.js";
23
+ import { loadAllowlist, matchAllowlist, appendAllowlist, derivePattern, _urlHost, } from "./settings.js";
24
+ import { backgroundRegistry } from "./background.js";
25
+ import { setTodos, renderTodos } from "./todos.js";
26
+ import { listSkills, loadSkill, renderSkillList } from "./skills.js";
24
27
  export const TOOL_MANIFEST = [
25
28
  {
26
29
  name: "read_file",
@@ -102,6 +105,137 @@ export const TOOL_MANIFEST = [
102
105
  required: ["message"],
103
106
  },
104
107
  },
108
+ {
109
+ name: "edit",
110
+ description: "Make a surgical edit to a file: replace an exact string with new text, leaving the rest untouched. Prefer this over write_file for changing part of a file. old_string must appear exactly once unless replace_all is set. Shows a diff + prompts for approval.",
111
+ input_schema: {
112
+ type: "object",
113
+ properties: {
114
+ path: { type: "string", description: "Absolute or session-cwd-relative path to the file to edit" },
115
+ old_string: { type: "string", description: "The exact text to replace (must match the file exactly, including whitespace)" },
116
+ new_string: { type: "string", description: "The text to replace it with (must differ from old_string)" },
117
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring old_string to be unique (default false)" },
118
+ },
119
+ required: ["path", "old_string", "new_string"],
120
+ },
121
+ },
122
+ {
123
+ name: "grep",
124
+ description: "Search file contents for a regular expression across a directory tree. Returns matching lines as path:line:text. Read-only, no approval needed.",
125
+ input_schema: {
126
+ type: "object",
127
+ properties: {
128
+ pattern: { type: "string", description: "Regular expression to search for (JavaScript regex syntax)" },
129
+ path: { type: "string", description: "Directory to search under (default: session cwd)" },
130
+ glob: { type: "string", description: "Optional file-name glob to limit which files are searched (e.g. *.ts, **/*.py)" },
131
+ ignore_case: { type: "boolean", description: "Case-insensitive match (default false)" },
132
+ max_results: { type: "integer", description: "Maximum matching lines to return (default 200)" },
133
+ },
134
+ required: ["pattern"],
135
+ },
136
+ },
137
+ {
138
+ name: "glob",
139
+ description: "Find files whose path matches a glob pattern across a directory tree, sorted by path. Returns paths relative to the search directory. Read-only, no approval needed.",
140
+ input_schema: {
141
+ type: "object",
142
+ properties: {
143
+ pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts, src/**/index.*)" },
144
+ path: { type: "string", description: "Directory to search under (default: session cwd)" },
145
+ },
146
+ required: ["pattern"],
147
+ },
148
+ },
149
+ {
150
+ name: "web_fetch",
151
+ description: "Fetch a web page or API over http(s) and return its text content (HTML is reduced to readable text). Shows the URL + prompts for approval, since the request leaves the user's machine.",
152
+ input_schema: {
153
+ type: "object",
154
+ properties: {
155
+ url: { type: "string", description: "Absolute http(s) URL to fetch" },
156
+ max_chars: { type: "integer", description: "Maximum characters of body text to return (default 20000)" },
157
+ },
158
+ required: ["url"],
159
+ },
160
+ },
161
+ {
162
+ name: "run_background",
163
+ description: "Start a long-running shell command in the background (a build, dev server, or test watcher) without blocking. Returns a shell_id — poll it with background_output and stop it with kill_background. Shows the command + prompts for approval.",
164
+ input_schema: {
165
+ type: "object",
166
+ properties: {
167
+ command: { type: "string", description: "Shell command line to run in the background" },
168
+ cwd: { type: "string", description: "Optional working directory (default: session cwd)" },
169
+ },
170
+ required: ["command"],
171
+ },
172
+ },
173
+ {
174
+ name: "background_output",
175
+ description: "Read output produced by a background command since the last read, plus its running/exited status. Read-only, no approval needed.",
176
+ input_schema: {
177
+ type: "object",
178
+ properties: {
179
+ shell_id: { type: "string", description: "The shell_id returned by run_background" },
180
+ },
181
+ required: ["shell_id"],
182
+ },
183
+ },
184
+ {
185
+ name: "kill_background",
186
+ description: "Terminate a background command started with run_background. No approval needed (you are stopping your own process).",
187
+ input_schema: {
188
+ type: "object",
189
+ properties: {
190
+ shell_id: { type: "string", description: "The shell_id returned by run_background" },
191
+ },
192
+ required: ["shell_id"],
193
+ },
194
+ },
195
+ {
196
+ name: "todo_write",
197
+ description: "Record or update the task list for a multi-step job. Pass the ENTIRE list every time (it replaces the previous one), each item an object { content, status } where status is pending, in_progress, or completed. Keep exactly one task in_progress. Read-only to the filesystem; no approval needed.",
198
+ input_schema: {
199
+ type: "object",
200
+ properties: {
201
+ todos: {
202
+ type: "array",
203
+ description: "Full task list — array of { content: string, status: \"pending\"|\"in_progress\"|\"completed\" }",
204
+ },
205
+ },
206
+ required: ["todos"],
207
+ },
208
+ },
209
+ {
210
+ name: "present_plan",
211
+ description: "Present a plan for the user to approve BEFORE you make any changes. Use this once you've finished researching and are ready to act — lay out the steps, then wait. The user approves or rejects; only proceed on approval. Always prompts (never auto-approved).",
212
+ input_schema: {
213
+ type: "object",
214
+ properties: {
215
+ plan: { type: "string", description: "The plan to execute, in markdown — the concrete steps you intend to take" },
216
+ },
217
+ required: ["plan"],
218
+ },
219
+ },
220
+ {
221
+ name: "list_skills",
222
+ description: "List the skills available in this project (./.claude/skills) and the user's global skills (~/.xiaotime/skills) — name + one-line description. Load one with load_skill. Read-only, no approval needed.",
223
+ input_schema: {
224
+ type: "object",
225
+ properties: {},
226
+ },
227
+ },
228
+ {
229
+ name: "load_skill",
230
+ description: "Load a local skill's full instructions by name (discover names with list_skills), then follow them. Read-only, no approval needed.",
231
+ input_schema: {
232
+ type: "object",
233
+ properties: {
234
+ name: { type: "string", description: "The skill name from list_skills" },
235
+ },
236
+ required: ["name"],
237
+ },
238
+ },
105
239
  ];
106
240
  // Session cwd — set on connect
107
241
  let sessionCwd = process.cwd();
@@ -300,6 +434,252 @@ export async function executeTool(call, ui) {
300
434
  console.log(chalk.hex("#a6e3a1")(" ✓ Committed") + _approvedSuffix(gate));
301
435
  return { tool_use_id, content: out, is_error: false };
302
436
  }
437
+ case "edit": {
438
+ const filePath = resolvePath(input.path);
439
+ const oldString = input.old_string;
440
+ const newString = input.new_string;
441
+ const replaceAll = input.replace_all;
442
+ if (!fs.existsSync(filePath)) {
443
+ return { tool_use_id, content: `File not found: ${filePath}`, is_error: true };
444
+ }
445
+ if (oldString === newString) {
446
+ return {
447
+ tool_use_id,
448
+ content: "old_string and new_string are identical — nothing to change.",
449
+ is_error: true,
450
+ };
451
+ }
452
+ const oldContent = fs.readFileSync(filePath, "utf-8");
453
+ const occurrences = _countOccurrences(oldContent, oldString);
454
+ if (occurrences === 0) {
455
+ return {
456
+ tool_use_id,
457
+ content: "old_string not found in file. It must match exactly, including whitespace and indentation.",
458
+ is_error: true,
459
+ };
460
+ }
461
+ if (occurrences > 1 && !replaceAll) {
462
+ return {
463
+ tool_use_id,
464
+ content: `old_string is not unique (${occurrences} matches). Add surrounding context to make it unique, or set replace_all: true.`,
465
+ is_error: true,
466
+ };
467
+ }
468
+ const newContent = replaceAll
469
+ ? oldContent.split(oldString).join(newString)
470
+ : _replaceFirst(oldContent, oldString, newString);
471
+ const replaced = replaceAll ? occurrences : 1;
472
+ // Show styled diff box before the approval gate (same as write_file)
473
+ printFileDiff(filePath, oldContent, newContent);
474
+ const gate = await _gateApproval(ui, "edit", input, `edit ${filePath}`, `Replacing ${replaced} occurrence${replaced === 1 ? "" : "s"}`);
475
+ if (!gate.approved) {
476
+ const tail = gate.rejectReason ? `: ${gate.rejectReason}` : ".";
477
+ return { tool_use_id, content: `Rejected by user${tail}`, is_error: false };
478
+ }
479
+ fs.writeFileSync(filePath, newContent, "utf-8");
480
+ console.log(chalk.hex("#a6e3a1")(" ✓ Edited") + _approvedSuffix(gate));
481
+ return {
482
+ tool_use_id,
483
+ content: `File edited: ${filePath} (${replaced} replacement${replaced === 1 ? "" : "s"})`,
484
+ is_error: false,
485
+ };
486
+ }
487
+ case "grep": {
488
+ const searchDir = input.path ? resolvePath(input.path) : sessionCwd;
489
+ const flags = input.ignore_case ? "i" : "";
490
+ let regex;
491
+ try {
492
+ regex = new RegExp(input.pattern, flags);
493
+ }
494
+ catch (e) {
495
+ return {
496
+ tool_use_id,
497
+ content: `Invalid regular expression: ${e.message}`,
498
+ is_error: true,
499
+ };
500
+ }
501
+ const globPat = input.glob;
502
+ const max = input.max_results || 200;
503
+ const { matches, truncated } = grepTree(searchDir, regex, globPat, max);
504
+ console.log(chalk.dim(` [grep] ${input.pattern} in ${searchDir} (${matches.length} match${matches.length === 1 ? "" : "es"})`));
505
+ const marker = truncated
506
+ ? `\n... (truncated to ${max} matches — narrow the pattern or glob)`
507
+ : "";
508
+ return {
509
+ tool_use_id,
510
+ content: (matches.join("\n") || "(no matches)") + marker,
511
+ is_error: false,
512
+ };
513
+ }
514
+ case "glob": {
515
+ const searchDir = input.path ? resolvePath(input.path) : sessionCwd;
516
+ const cap = _listDirectoryMax();
517
+ let entries = listRecursive(searchDir, input.pattern, cap + 1);
518
+ entries.sort();
519
+ let truncated = false;
520
+ if (entries.length > cap) {
521
+ entries = entries.slice(0, cap);
522
+ truncated = true;
523
+ }
524
+ console.log(chalk.dim(` [glob] ${input.pattern} in ${searchDir} (${entries.length} file${entries.length === 1 ? "" : "s"})`));
525
+ const marker = truncated ? `\n... (truncated to ${cap})` : "";
526
+ return {
527
+ tool_use_id,
528
+ content: (entries.join("\n") || "(no matches)") + marker,
529
+ is_error: false,
530
+ };
531
+ }
532
+ case "web_fetch": {
533
+ const url = String(input.url ?? "").trim();
534
+ const host = _urlHost(url);
535
+ if (host === null) {
536
+ return {
537
+ tool_use_id,
538
+ content: "web_fetch requires an absolute http(s) URL.",
539
+ is_error: true,
540
+ };
541
+ }
542
+ const maxChars = input.max_chars || 20000;
543
+ const gate = await _gateApproval(ui, "web_fetch", input, `web_fetch ${host}`, url);
544
+ if (!gate.approved) {
545
+ const tail = gate.rejectReason ? `: ${gate.rejectReason}` : ".";
546
+ return { tool_use_id, content: `Rejected by user${tail}`, is_error: false };
547
+ }
548
+ let body;
549
+ try {
550
+ const controller = new AbortController();
551
+ const timer = setTimeout(() => controller.abort(), 30000);
552
+ const resp = await fetch(url, {
553
+ signal: controller.signal,
554
+ redirect: "follow",
555
+ headers: { "User-Agent": "xiaotime-cli" },
556
+ });
557
+ clearTimeout(timer);
558
+ if (!resp.ok) {
559
+ return {
560
+ tool_use_id,
561
+ content: `HTTP ${resp.status} ${resp.statusText} for ${url}`,
562
+ is_error: true,
563
+ };
564
+ }
565
+ const ctype = resp.headers.get("content-type") || "";
566
+ const raw = await resp.text();
567
+ body = /html/i.test(ctype) ? stripHtml(raw) : raw;
568
+ }
569
+ catch (e) {
570
+ return {
571
+ tool_use_id,
572
+ content: `Fetch failed: ${e.message}`,
573
+ is_error: true,
574
+ };
575
+ }
576
+ const truncated = body.length > maxChars;
577
+ console.log(chalk.hex("#a6e3a1")(" ✓ Fetched") +
578
+ _approvedSuffix(gate) +
579
+ chalk.dim(` (${body.length} chars)`));
580
+ return {
581
+ tool_use_id,
582
+ content: body.slice(0, maxChars) + (truncated ? `\n... (truncated to ${maxChars} chars)` : ""),
583
+ is_error: false,
584
+ };
585
+ }
586
+ case "run_background": {
587
+ const command = input.command;
588
+ const cwd = input.cwd ? resolvePath(input.cwd) : sessionCwd;
589
+ const gate = await _gateApproval(ui, "run_background", input, `run_background`, `${command}\ncwd: ${cwd}`);
590
+ if (!gate.approved) {
591
+ const tail = gate.rejectReason ? `: ${gate.rejectReason}` : ".";
592
+ return { tool_use_id, content: `Rejected by user${tail}`, is_error: false };
593
+ }
594
+ const proc = backgroundRegistry.start(command, cwd);
595
+ console.log(chalk.hex("#a6e3a1")(` ✓ Background started (${proc.id})`) + _approvedSuffix(gate));
596
+ return {
597
+ tool_use_id,
598
+ content: `Background command started: ${proc.id}. Poll output with background_output (shell_id "${proc.id}"); stop it with kill_background.`,
599
+ is_error: false,
600
+ };
601
+ }
602
+ case "background_output": {
603
+ const shellId = input.shell_id;
604
+ const read = backgroundRegistry.readNew(shellId);
605
+ if (read === null) {
606
+ return { tool_use_id, content: `No such background command: ${shellId}`, is_error: true };
607
+ }
608
+ console.log(chalk.dim(` [background_output] ${shellId} (${read.status})`));
609
+ const exitNote = read.status === "running"
610
+ ? ""
611
+ : `\n[${read.status}${read.exitCode !== null ? `, exit ${read.exitCode}` : ""}]`;
612
+ return {
613
+ tool_use_id,
614
+ content: (read.text || "(no new output)") + exitNote,
615
+ is_error: false,
616
+ };
617
+ }
618
+ case "kill_background": {
619
+ const shellId = input.shell_id;
620
+ const ok = backgroundRegistry.kill(shellId);
621
+ if (!ok) {
622
+ return { tool_use_id, content: `No such background command: ${shellId}`, is_error: true };
623
+ }
624
+ console.log(chalk.dim(` [kill_background] ${shellId}`));
625
+ return { tool_use_id, content: `Killed background command: ${shellId}`, is_error: false };
626
+ }
627
+ case "todo_write": {
628
+ let rendered;
629
+ try {
630
+ const list = setTodos(input.todos);
631
+ rendered = renderTodos(list);
632
+ }
633
+ catch (e) {
634
+ return { tool_use_id, content: e.message, is_error: true };
635
+ }
636
+ console.log(chalk.dim(` [todo_write]\n${rendered.split("\n").map((l) => " " + l).join("\n")}`));
637
+ return { tool_use_id, content: rendered, is_error: false };
638
+ }
639
+ case "present_plan": {
640
+ const plan = String(input.plan ?? "").trim();
641
+ if (!plan) {
642
+ return { tool_use_id, content: "present_plan requires a non-empty plan.", is_error: true };
643
+ }
644
+ // Always prompt — a plan is unique, so it is never allowlisted.
645
+ printApprovalPrompt("Plan — approve before I act?", plan);
646
+ const decision = await ui.promptApprovalMenu("");
647
+ if (!decision.approved) {
648
+ const tail = decision.rejectReason ? `: ${decision.rejectReason}` : ".";
649
+ return {
650
+ tool_use_id,
651
+ content: `Plan rejected by user${tail} Do not proceed — revise the plan and present again.`,
652
+ is_error: false,
653
+ };
654
+ }
655
+ console.log(chalk.hex("#a6e3a1")(" ✓ Plan approved"));
656
+ return {
657
+ tool_use_id,
658
+ content: "Plan approved by user. Proceed with the steps as presented.",
659
+ is_error: false,
660
+ };
661
+ }
662
+ case "list_skills": {
663
+ const skills = listSkills(sessionCwd);
664
+ console.log(chalk.dim(` [list_skills] ${skills.length} skill${skills.length === 1 ? "" : "s"}`));
665
+ return { tool_use_id, content: renderSkillList(skills), is_error: false };
666
+ }
667
+ case "load_skill": {
668
+ const skillName = String(input.name ?? "").trim();
669
+ if (!skillName) {
670
+ return { tool_use_id, content: "load_skill requires a skill name.", is_error: true };
671
+ }
672
+ const content = loadSkill(skillName, sessionCwd);
673
+ if (content === null) {
674
+ return {
675
+ tool_use_id,
676
+ content: `No local skill named "${skillName}". Use list_skills to see what's available.`,
677
+ is_error: true,
678
+ };
679
+ }
680
+ console.log(chalk.dim(` [load_skill] ${skillName}`));
681
+ return { tool_use_id, content, is_error: false };
682
+ }
303
683
  default:
304
684
  return {
305
685
  tool_use_id,
@@ -358,4 +738,119 @@ export function globToRegex(glob) {
358
738
  .replace(/\?/g, "[^/]");
359
739
  return new RegExp(escaped);
360
740
  }
741
+ /** Count non-overlapping occurrences of `needle` in `hay`. */
742
+ export function _countOccurrences(hay, needle) {
743
+ if (needle === "")
744
+ return 0;
745
+ let count = 0;
746
+ let i = 0;
747
+ while ((i = hay.indexOf(needle, i)) !== -1) {
748
+ count++;
749
+ i += needle.length;
750
+ }
751
+ return count;
752
+ }
753
+ /**
754
+ * Replace the first literal occurrence of `needle` with `repl`.
755
+ * Uses index-splicing (not String.replace) so `$&`/`$1` sequences in
756
+ * `repl` are inserted verbatim rather than interpreted as regex
757
+ * back-references (#14608 — the surgical-edit correctness trap).
758
+ */
759
+ export function _replaceFirst(hay, needle, repl) {
760
+ const i = hay.indexOf(needle);
761
+ if (i === -1)
762
+ return hay;
763
+ return hay.slice(0, i) + repl + hay.slice(i + needle.length);
764
+ }
765
+ /**
766
+ * Recursively grep a directory tree. Skips dotfiles, node_modules,
767
+ * binary files (any NUL byte), and files larger than 2 MB. Returns
768
+ * `relpath:lineno:linetext` matches, capped at `cap`. Directory entries
769
+ * are visited in sorted order for deterministic output.
770
+ */
771
+ export function grepTree(dir, regex, glob, cap = 200) {
772
+ const matches = [];
773
+ const globRe = glob ? globToRegex(glob) : null;
774
+ const MAX_FILE = 2 * 1024 * 1024;
775
+ let truncated = false;
776
+ function walk(current, rel) {
777
+ let entries;
778
+ try {
779
+ entries = fs.readdirSync(current);
780
+ }
781
+ catch {
782
+ return false;
783
+ }
784
+ entries.sort();
785
+ for (const entry of entries) {
786
+ if (entry.startsWith("."))
787
+ continue;
788
+ if (entry === "node_modules")
789
+ continue;
790
+ const full = path.join(current, entry);
791
+ const relPath = rel ? `${rel}/${entry}` : entry;
792
+ let stat;
793
+ try {
794
+ stat = fs.statSync(full);
795
+ }
796
+ catch {
797
+ continue;
798
+ }
799
+ if (stat.isDirectory()) {
800
+ if (walk(full, relPath))
801
+ return true;
802
+ }
803
+ else {
804
+ if (globRe && !globRe.test(relPath))
805
+ continue;
806
+ if (stat.size > MAX_FILE)
807
+ continue;
808
+ let content;
809
+ try {
810
+ content = fs.readFileSync(full, "utf-8");
811
+ }
812
+ catch {
813
+ continue;
814
+ }
815
+ if (/\u0000/.test(content))
816
+ continue; // binary (any NUL byte)
817
+ const lines = content.split("\n");
818
+ for (let i = 0; i < lines.length; i++) {
819
+ if (regex.test(lines[i])) {
820
+ matches.push(`${relPath}:${i + 1}:${lines[i]}`);
821
+ if (matches.length >= cap) {
822
+ truncated = true;
823
+ return true;
824
+ }
825
+ }
826
+ }
827
+ }
828
+ }
829
+ return false;
830
+ }
831
+ walk(dir, "");
832
+ return { matches, truncated };
833
+ }
834
+ /**
835
+ * Reduce an HTML document to readable text: drop script/style/comments
836
+ * and tags, decode the common named entities, and collapse whitespace.
837
+ * Intentionally minimal — enough to make a fetched page legible to the
838
+ * agent, not a full HTML parser.
839
+ */
840
+ export function stripHtml(html) {
841
+ return html
842
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
843
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
844
+ .replace(/<!--[\s\S]*?-->/g, " ")
845
+ .replace(/<[^>]+>/g, " ")
846
+ .replace(/&nbsp;/g, " ")
847
+ .replace(/&amp;/g, "&")
848
+ .replace(/&lt;/g, "<")
849
+ .replace(/&gt;/g, ">")
850
+ .replace(/&quot;/g, '"')
851
+ .replace(/&#39;/g, "'")
852
+ .replace(/[ \t]+/g, " ")
853
+ .replace(/\n\s*\n\s*\n+/g, "\n\n")
854
+ .trim();
855
+ }
361
856
  //# sourceMappingURL=tools.js.map