@cryer/star-cli 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2469 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/agent/loop.ts
4
+ import { tool as aiTool } from "ai";
5
+
6
+ // src/context/compaction.ts
7
+ import { generateText } from "ai";
8
+
9
+ // src/context/tokens.ts
10
+ var CHARS_PER_TOKEN = 4;
11
+ var MESSAGE_OVERHEAD = 4;
12
+ var IMAGE_PART_TOKENS = 1024;
13
+ var CJK_RE = /[\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/;
14
+ function textWeight(text) {
15
+ let cjk = 0;
16
+ for (const ch of text) {
17
+ if (CJK_RE.test(ch)) cjk++;
18
+ }
19
+ return text.length + cjk * (CHARS_PER_TOKEN - 1);
20
+ }
21
+ function contentCharLength(message) {
22
+ if (message.role === "tool") {
23
+ let chars2 = 0;
24
+ for (const part of message.content) {
25
+ chars2 += textWeight(JSON.stringify(part.result ?? null));
26
+ }
27
+ return chars2;
28
+ }
29
+ if (typeof message.content === "string") {
30
+ return textWeight(message.content);
31
+ }
32
+ let chars = 0;
33
+ for (const part of message.content) {
34
+ if (part.type === "text" || part.type === "reasoning") {
35
+ chars += textWeight(part.text);
36
+ } else if (part.type === "tool-call") {
37
+ chars += textWeight(part.toolName) + textWeight(JSON.stringify(part.args ?? null));
38
+ }
39
+ }
40
+ return chars;
41
+ }
42
+ function imagePartCount(message) {
43
+ if (typeof message.content === "string" || message.role === "tool") return 0;
44
+ return message.content.filter((part) => part.type === "image" || part.type === "file").length;
45
+ }
46
+ function estimateMessageTokens(message) {
47
+ return Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
48
+ }
49
+ function estimateTokens(messages) {
50
+ return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
51
+ }
52
+
53
+ // src/context/compaction.ts
54
+ var MIN_KEPT_MESSAGES = 4;
55
+ function placeholderMessage(droppedCount) {
56
+ return {
57
+ role: "user",
58
+ content: `[context compacted: ${droppedCount} earlier messages dropped]`
59
+ };
60
+ }
61
+ var SUMMARY_SYSTEM_PROMPT = [
62
+ "You are summarizing an AI coding agent's conversation for context compaction.",
63
+ "Write a concise summary that preserves:",
64
+ "- the user's goals and requests",
65
+ "- decisions made and their rationale",
66
+ "- files and code touched (paths, key changes)",
67
+ "- tool results that matter (errors, key outputs)",
68
+ "- outstanding TODOs and next steps",
69
+ "Output only the summary, no preamble."
70
+ ].join("\n");
71
+ function serializeMessage(message) {
72
+ if (typeof message.content === "string") {
73
+ return `${message.role}: ${message.content}`;
74
+ }
75
+ const parts = message.content.map((part) => {
76
+ if (part.type === "text") return part.text;
77
+ if (part.type === "tool-call")
78
+ return `[tool-call ${part.toolName}] ${JSON.stringify(part.args)}`;
79
+ if (part.type === "tool-result")
80
+ return `[tool-result ${part.toolName}] ${JSON.stringify(part.result)}`;
81
+ return `[${part.type}]`;
82
+ });
83
+ return `${message.role}: ${parts.join("\n")}`;
84
+ }
85
+ async function summarizeMessages(messages, model) {
86
+ const transcript = messages.map(serializeMessage).join("\n");
87
+ const { text } = await generateText({
88
+ model,
89
+ system: SUMMARY_SYSTEM_PROMPT,
90
+ prompt: `Summarize this conversation so far:
91
+
92
+ ${transcript}`
93
+ });
94
+ return text.trim();
95
+ }
96
+ function compactMessages(messages, maxTokens) {
97
+ if (estimateTokens(messages) <= maxTokens) {
98
+ return { messages, compacted: false, droppedCount: 0 };
99
+ }
100
+ const first = messages[0];
101
+ const hasSystem = first?.role === "system";
102
+ const head = hasSystem && first ? [first] : [];
103
+ const rest = hasSystem ? messages.slice(1) : messages.slice();
104
+ const turns = [];
105
+ for (const message of rest) {
106
+ const current = turns[turns.length - 1];
107
+ if (message.role === "user" || !current) {
108
+ turns.push([message]);
109
+ } else {
110
+ current.push(message);
111
+ }
112
+ }
113
+ let droppedCount = 0;
114
+ let turnIndex = 0;
115
+ while (turnIndex < turns.length) {
116
+ const turn = turns[turnIndex];
117
+ if (rest.length - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
118
+ break;
119
+ }
120
+ droppedCount += turn.length;
121
+ turnIndex += 1;
122
+ const candidate = [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)];
123
+ if (estimateTokens(candidate) <= maxTokens) {
124
+ break;
125
+ }
126
+ }
127
+ if (droppedCount === 0) {
128
+ return { messages, compacted: false, droppedCount: 0 };
129
+ }
130
+ return {
131
+ messages: [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)],
132
+ compacted: true,
133
+ droppedCount
134
+ };
135
+ }
136
+
137
+ // src/core/git.ts
138
+ import { execFileSync } from "child_process";
139
+ var GIT_TIMEOUT_MS = 2e3;
140
+ var GIT_MAX_BUFFER = 8 * 1024 * 1024;
141
+ var COMMIT_DIFF_MAX_LINES = 2e3;
142
+ var WORKING_DIFF_MAX_LINES = 2e3;
143
+ function runGit(args, cwd) {
144
+ try {
145
+ const out = execFileSync("git", args, {
146
+ cwd,
147
+ timeout: GIT_TIMEOUT_MS,
148
+ encoding: "utf8",
149
+ maxBuffer: GIT_MAX_BUFFER,
150
+ stdio: ["ignore", "pipe", "ignore"]
151
+ });
152
+ return out.replace(/\n$/, "");
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ function isGitRepo(cwd) {
158
+ return runGit(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
159
+ }
160
+ function getGitSummary(cwd) {
161
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
162
+ if (branch === null) return null;
163
+ const status = runGit(["status", "--porcelain"], cwd) ?? "";
164
+ const dirtyCount = status === "" ? 0 : status.split("\n").length;
165
+ const log = runGit(["log", "--oneline", "-3"], cwd) ?? "";
166
+ return { branch, dirtyCount, recentCommits: log === "" ? [] : log.split("\n") };
167
+ }
168
+ function formatGitSummary(summary) {
169
+ const lines = [
170
+ "Git context for the working directory (refreshed each turn):",
171
+ `Branch: ${summary.branch}`,
172
+ summary.dirtyCount === 0 ? "Working tree: clean" : `Working tree: ${summary.dirtyCount} file(s) with uncommitted changes`
173
+ ];
174
+ if (summary.recentCommits.length > 0) {
175
+ lines.push("Recent commits:", ...summary.recentCommits);
176
+ }
177
+ return lines.join("\n");
178
+ }
179
+ function truncateLines(text, maxLines) {
180
+ const lines = text.split("\n");
181
+ if (lines.length <= maxLines) return { text, truncated: false };
182
+ return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
183
+ }
184
+ function collectDiffs(cwd) {
185
+ return {
186
+ staged: runGit(["diff", "--cached"], cwd) ?? "",
187
+ unstaged: runGit(["diff"], cwd) ?? ""
188
+ };
189
+ }
190
+ function collectCommitContext(cwd) {
191
+ if (!isGitRepo(cwd)) return null;
192
+ const status = runGit(["status", "--short"], cwd) ?? "";
193
+ const { staged, unstaged } = collectDiffs(cwd);
194
+ if (status === "" && staged === "" && unstaged === "") return null;
195
+ const rawDiff = [staged, unstaged].filter((part) => part !== "").join("\n");
196
+ const { text: diff, truncated } = truncateLines(rawDiff, COMMIT_DIFF_MAX_LINES);
197
+ const recentLog = runGit(["log", "--oneline", "-5"], cwd) ?? "";
198
+ return { status, diff, diffTruncated: truncated, recentLog };
199
+ }
200
+ function buildCommitPrompt(context, instructions = "") {
201
+ const parts = [
202
+ "Analyze the uncommitted git changes below and create a commit for them.",
203
+ "",
204
+ "Requirements:",
205
+ "- Draft a commit message in Conventional Commits style (e.g. `feat(scope): summary`), consistent with the repository's recent commit history.",
206
+ "- Stage the relevant files and create the commit with the bash tool (`git add` + `git commit`), through the normal permission flow.",
207
+ "- Do not push and do not amend existing commits.",
208
+ "- After committing, briefly report the commit hash and message."
209
+ ];
210
+ if (instructions) {
211
+ parts.push("", `Additional instructions from the user: ${instructions}`);
212
+ }
213
+ parts.push("", "## git status --short", "```", context.status || "(clean)", "```");
214
+ if (context.recentLog) {
215
+ parts.push("", "## recent commits (git log --oneline -5)", "```", context.recentLog, "```");
216
+ }
217
+ if (context.diff) {
218
+ parts.push("", "## git diff (staged + unstaged)", "```diff", context.diff, "```");
219
+ if (context.diffTruncated) {
220
+ parts.push(`(diff truncated to ${COMMIT_DIFF_MAX_LINES} lines)`);
221
+ }
222
+ }
223
+ return parts.join("\n");
224
+ }
225
+ function collectWorkingDiff(cwd) {
226
+ if (!isGitRepo(cwd)) return null;
227
+ const status = runGit(["status", "--short"], cwd) ?? "";
228
+ const { staged, unstaged } = collectDiffs(cwd);
229
+ const sections = [];
230
+ if (staged) sections.push(`# Staged changes
231
+ ${staged}`);
232
+ if (unstaged) sections.push(`# Unstaged changes
233
+ ${unstaged}`);
234
+ const { text, truncated } = truncateLines(sections.join("\n"), WORKING_DIFF_MAX_LINES);
235
+ return { status, diff: text, truncated };
236
+ }
237
+
238
+ // src/core/messages.ts
239
+ var MISSING_TOOL_RESULT_TEXT = "tool result missing (session was interrupted)";
240
+ function reconcileToolCalls(messages) {
241
+ const result = [];
242
+ let pending = [];
243
+ const flushPending = () => {
244
+ for (const call of pending) {
245
+ result.push({
246
+ role: "tool",
247
+ content: [
248
+ {
249
+ type: "tool-result",
250
+ toolCallId: call.toolCallId,
251
+ toolName: call.toolName,
252
+ result: MISSING_TOOL_RESULT_TEXT
253
+ }
254
+ ]
255
+ });
256
+ }
257
+ pending = [];
258
+ };
259
+ for (const message of messages) {
260
+ if (message.role === "assistant" && Array.isArray(message.content)) {
261
+ flushPending();
262
+ pending = message.content.filter((part) => part.type === "tool-call").map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName }));
263
+ } else if (message.role === "tool" && Array.isArray(message.content) && pending.length > 0) {
264
+ const answered = new Set(message.content.map((part) => part.toolCallId));
265
+ pending = pending.filter((call) => !answered.has(call.toolCallId));
266
+ } else {
267
+ flushPending();
268
+ }
269
+ result.push(message);
270
+ }
271
+ flushPending();
272
+ return result;
273
+ }
274
+ function retractLastTurn(messages) {
275
+ for (let i = messages.length - 1; i >= 0; i--) {
276
+ if (messages[i]?.role === "user") {
277
+ return { messages: messages.slice(0, i), removed: messages.length - i };
278
+ }
279
+ }
280
+ return { messages, removed: 0 };
281
+ }
282
+
283
+ // src/hooks/runner.ts
284
+ import { spawn } from "child_process";
285
+ var STDERR_LIMIT = 2e3;
286
+ var STDERR_CAPTURE_LIMIT = 64 * 1024;
287
+ function truncate(text) {
288
+ const trimmed = text.trim();
289
+ return trimmed.length > STDERR_LIMIT ? `${trimmed.slice(0, STDERR_LIMIT)}\u2026` : trimmed;
290
+ }
291
+ function killTree(pid) {
292
+ if (pid === void 0) return;
293
+ if (process.platform === "win32") {
294
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true }).on(
295
+ "error",
296
+ () => {
297
+ }
298
+ );
299
+ } else {
300
+ try {
301
+ process.kill(-pid, "SIGKILL");
302
+ } catch {
303
+ try {
304
+ process.kill(pid, "SIGKILL");
305
+ } catch {
306
+ }
307
+ }
308
+ }
309
+ }
310
+ function runCommand(hook, event, ctx) {
311
+ const env = {
312
+ ...process.env,
313
+ STAR_HOOK_EVENT: event,
314
+ STAR_CWD: ctx.cwd,
315
+ STAR_SESSION_ID: ctx.sessionId ?? ""
316
+ };
317
+ if (ctx.toolName !== void 0) env.STAR_TOOL_NAME = ctx.toolName;
318
+ if (ctx.toolInput !== void 0) env.STAR_TOOL_INPUT = JSON.stringify(ctx.toolInput);
319
+ return new Promise((resolve) => {
320
+ let child;
321
+ try {
322
+ child = spawn(hook.command, [], {
323
+ shell: true,
324
+ cwd: ctx.cwd,
325
+ env,
326
+ windowsHide: true,
327
+ detached: process.platform !== "win32"
328
+ });
329
+ } catch (error) {
330
+ resolve({
331
+ code: 1,
332
+ stderr: "",
333
+ timedOut: false,
334
+ spawnError: error instanceof Error ? error.message : String(error)
335
+ });
336
+ return;
337
+ }
338
+ let stderr = "";
339
+ let timedOut = false;
340
+ let settled = false;
341
+ const finish = (outcome) => {
342
+ if (settled) return;
343
+ settled = true;
344
+ clearTimeout(timer);
345
+ clearTimeout(fallback);
346
+ resolve(outcome);
347
+ };
348
+ const timer = setTimeout(() => {
349
+ timedOut = true;
350
+ killTree(child.pid);
351
+ child.stdout?.destroy();
352
+ child.stderr?.destroy();
353
+ fallback = setTimeout(() => {
354
+ finish({ code: 1, stderr, timedOut: true });
355
+ }, 2e3);
356
+ fallback.unref?.();
357
+ }, hook.timeoutSec * 1e3);
358
+ let fallback;
359
+ child.stderr?.on("data", (chunk) => {
360
+ if (stderr.length < STDERR_CAPTURE_LIMIT) stderr += chunk.toString("utf8");
361
+ });
362
+ child.stdout?.resume();
363
+ child.on("error", (error) => {
364
+ finish({ code: 1, stderr, timedOut, spawnError: error.message });
365
+ });
366
+ child.on("close", (code) => {
367
+ finish({ code: code ?? 1, stderr, timedOut });
368
+ });
369
+ });
370
+ }
371
+ function hookMatches(hook, event, toolName) {
372
+ if (hook.event !== event) return false;
373
+ if (event === "Stop" || !hook.matcher) return true;
374
+ if (toolName === void 0) return false;
375
+ try {
376
+ return new RegExp(hook.matcher).test(toolName);
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+ async function runHooks(event, hooks2, ctx) {
382
+ const result = { blocked: false, warnings: [] };
383
+ for (const hook of hooks2) {
384
+ if (!hookMatches(hook, event, ctx.toolName)) continue;
385
+ let outcome;
386
+ try {
387
+ outcome = await runCommand(hook, event, ctx);
388
+ } catch (error) {
389
+ result.warnings.push(
390
+ `Hook "${hook.command}" (${event}) failed: ${error instanceof Error ? error.message : String(error)}`
391
+ );
392
+ continue;
393
+ }
394
+ if (outcome.code === 0) continue;
395
+ const stderr = truncate(outcome.stderr);
396
+ if (event === "PreToolUse" && outcome.code === 2 && !outcome.timedOut) {
397
+ result.blocked = true;
398
+ result.reason = stderr || `hook "${hook.command}" exited with code 2`;
399
+ return result;
400
+ }
401
+ const detail = outcome.timedOut ? `timed out after ${hook.timeoutSec}s` : outcome.spawnError ? `failed to start: ${outcome.spawnError}` : `exited with code ${outcome.code}`;
402
+ result.warnings.push(
403
+ `Hook "${hook.command}" (${event}) ${detail}${stderr ? `: ${stderr}` : ""}`
404
+ );
405
+ }
406
+ return result;
407
+ }
408
+
409
+ // src/permissions/gate.ts
410
+ import path from "path";
411
+
412
+ // src/permissions/allow.ts
413
+ function parseAllowRule(rule) {
414
+ const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
415
+ if (!match) return null;
416
+ return { toolName: match[1], pattern: match[2] };
417
+ }
418
+ function globMatch(pattern, value) {
419
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
420
+ return new RegExp(`^${escaped}$`).test(value);
421
+ }
422
+ function requestTarget(req) {
423
+ if (typeof req.args !== "object" || req.args === null) return void 0;
424
+ const args = req.args;
425
+ const key = req.toolName === "bash" ? "command" : "path";
426
+ const value = args[key];
427
+ return typeof value === "string" ? value : void 0;
428
+ }
429
+ function matchAllowRule(rule, req) {
430
+ if (rule.toolName !== req.toolName) return false;
431
+ if (rule.pattern === void 0) return true;
432
+ const target = requestTarget(req);
433
+ return target !== void 0 && globMatch(rule.pattern, target);
434
+ }
435
+ function isAllowedByRules(rules, req) {
436
+ return rules.some((raw) => {
437
+ const rule = parseAllowRule(raw);
438
+ return rule !== null && matchAllowRule(rule, req);
439
+ });
440
+ }
441
+ function buildAllowRule(req) {
442
+ const target = requestTarget(req);
443
+ return target !== void 0 ? `${req.toolName}(${target})` : req.toolName;
444
+ }
445
+
446
+ // src/permissions/gate.ts
447
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
448
+ var DANGEROUS_COMMAND_PATTERNS = [
449
+ /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
450
+ /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
451
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
452
+ /\bmkfs\b/i,
453
+ /dd\s+[^|;]*\bif=/i,
454
+ />\s*\/dev\/sd[a-z]/i,
455
+ /\bshutdown\b/i,
456
+ /\breboot\b/i,
457
+ /\bpoweroff\b/i
458
+ ];
459
+ function isDangerousCommand(command) {
460
+ return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
461
+ }
462
+ function getStringArg(args, key) {
463
+ if (typeof args === "object" && args !== null) {
464
+ const value = args[key];
465
+ if (typeof value === "string") {
466
+ return value;
467
+ }
468
+ }
469
+ return void 0;
470
+ }
471
+ function normalizeAbsolute(p, cwd) {
472
+ const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
473
+ return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
474
+ }
475
+ function isOutsideCwd(p, cwd) {
476
+ const abs = normalizeAbsolute(p, cwd);
477
+ const base = normalizeAbsolute(cwd, cwd);
478
+ return abs !== base && !abs.startsWith(`${base}/`);
479
+ }
480
+ function isSensitivePath(p) {
481
+ const base = p.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
482
+ if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
483
+ return false;
484
+ }
485
+ return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
486
+ }
487
+ function checkPermission(mode, req, ctx, allowRules = []) {
488
+ if (mode === "yolo") {
489
+ return "allow";
490
+ }
491
+ const command = getStringArg(req.args, "command");
492
+ const filePath = getStringArg(req.args, "path");
493
+ if (req.toolName === "bash" && command !== void 0 && isDangerousCommand(command)) {
494
+ return "deny";
495
+ }
496
+ if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
497
+ if ((req.toolName === "write_file" || req.toolName === "edit_file") && isSensitivePath(filePath)) {
498
+ return "deny";
499
+ }
500
+ if (isOutsideCwd(filePath, ctx.cwd)) {
501
+ if (mode === "ask" && req.level === "read") {
502
+ return "ask";
503
+ }
504
+ return "deny";
505
+ }
506
+ }
507
+ if (mode === "auto") {
508
+ return "allow";
509
+ }
510
+ if (mode === "readonly") {
511
+ return req.level === "read" ? "allow" : "deny";
512
+ }
513
+ if (mode === "plan") {
514
+ return req.level === "read" ? "allow" : "deny";
515
+ }
516
+ if (isAllowedByRules(allowRules, req)) {
517
+ return "allow";
518
+ }
519
+ return req.level === "read" ? "allow" : "ask";
520
+ }
521
+
522
+ // src/session/title.ts
523
+ import { generateText as generateText2 } from "ai";
524
+ var TITLE_MAX_CHARS = 50;
525
+ var TITLE_INPUT_MAX_CHARS = 1e3;
526
+ var TITLE_MAX_TOKENS = 20;
527
+ var TITLE_TIMEOUT_MS = 15e3;
528
+ var TITLE_SYSTEM_PROMPT = [
529
+ "You write short titles for conversations.",
530
+ "Summarize the user's first message into a single concise title:",
531
+ "- at most 50 characters",
532
+ "- no surrounding quotes, no trailing punctuation",
533
+ "- same language as the user's message",
534
+ "Output only the title, nothing else."
535
+ ].join("\n");
536
+ function sanitizeTitle(raw) {
537
+ let title = raw.trim().split("\n")[0]?.trim() ?? "";
538
+ let previous;
539
+ do {
540
+ previous = title;
541
+ title = title.replace(/^["'“”‘’「」『』\s]+|["'“”‘’「」『』\s]+$/g, "").replace(/[。..!!??…::;;,,、\s]+$/u, "");
542
+ } while (title !== previous);
543
+ return title.slice(0, TITLE_MAX_CHARS).trim();
544
+ }
545
+ async function generateSessionTitle(userText, model) {
546
+ const { text } = await generateText2({
547
+ model,
548
+ system: TITLE_SYSTEM_PROMPT,
549
+ prompt: userText.slice(0, TITLE_INPUT_MAX_CHARS),
550
+ maxTokens: TITLE_MAX_TOKENS,
551
+ abortSignal: AbortSignal.timeout(TITLE_TIMEOUT_MS)
552
+ });
553
+ return sanitizeTitle(text);
554
+ }
555
+ function scheduleSessionTitle(store, userText, model) {
556
+ void (async () => {
557
+ const meta = await store.meta();
558
+ if (meta.title) return;
559
+ const title = await generateSessionTitle(userText, model);
560
+ if (!title) return;
561
+ const current = await store.meta();
562
+ if (current.title) return;
563
+ await store.setTitle(title);
564
+ })().catch(() => {
565
+ });
566
+ }
567
+
568
+ // src/tools/fs/snapshots.ts
569
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
570
+ import path2 from "path";
571
+ var MAX_SNAPSHOTS = 50;
572
+ var stack = [];
573
+ var activeTurn = 0;
574
+ var activeTurnMessageIndex = -1;
575
+ var checkpointSeq = 0;
576
+ var hooks = null;
577
+ function setSnapshotHooks(next) {
578
+ hooks = next;
579
+ }
580
+ function nextSnapshotId() {
581
+ return ++checkpointSeq;
582
+ }
583
+ function beginTurn(messageIndex = -1) {
584
+ activeTurnMessageIndex = messageIndex;
585
+ return ++activeTurn;
586
+ }
587
+ function currentTurnSeq() {
588
+ return activeTurn;
589
+ }
590
+ function currentTurnMessageIndex() {
591
+ return activeTurnMessageIndex;
592
+ }
593
+ async function captureSnapshot(filePath, toolName) {
594
+ let content = null;
595
+ let existed = false;
596
+ try {
597
+ content = await readFile(filePath, "utf8");
598
+ existed = true;
599
+ } catch {
600
+ }
601
+ return {
602
+ id: nextSnapshotId(),
603
+ path: filePath,
604
+ existed,
605
+ content,
606
+ toolName,
607
+ timestamp: Date.now(),
608
+ turn: activeTurn,
609
+ messageIndex: activeTurnMessageIndex
610
+ };
611
+ }
612
+ async function pushSnapshot(snapshot) {
613
+ stack.push(snapshot);
614
+ await hooks?.onPush?.(snapshot);
615
+ if (stack.length > MAX_SNAPSHOTS) {
616
+ const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
617
+ await removeFromStore(evicted.map((s) => s.id));
618
+ }
619
+ }
620
+ async function removeFromStore(ids) {
621
+ if (ids.length > 0) await hooks?.onRemove?.(ids);
622
+ }
623
+ function listSnapshots() {
624
+ return [...stack];
625
+ }
626
+ function clearSnapshots() {
627
+ stack.length = 0;
628
+ }
629
+ function hydrateSnapshots(snapshots) {
630
+ stack.length = 0;
631
+ stack.push(...snapshots.slice(-MAX_SNAPSHOTS));
632
+ for (const snapshot of stack) {
633
+ if (snapshot.id > checkpointSeq) checkpointSeq = snapshot.id;
634
+ }
635
+ }
636
+ async function resolveContent(snapshot) {
637
+ if (snapshot.content !== null) return snapshot.content;
638
+ if (!snapshot.contentFile) return null;
639
+ try {
640
+ return await readFile(snapshot.contentFile, "utf8");
641
+ } catch {
642
+ return null;
643
+ }
644
+ }
645
+ async function revert(snapshot) {
646
+ if (snapshot.existed) {
647
+ const content = await resolveContent(snapshot);
648
+ if (content === null) {
649
+ return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
650
+ }
651
+ await mkdir(path2.dirname(snapshot.path), { recursive: true });
652
+ await writeFile(snapshot.path, content, "utf8");
653
+ return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
654
+ }
655
+ await rm(snapshot.path, { force: true });
656
+ return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
657
+ }
658
+ async function undoTurnSnapshots(turn) {
659
+ const matches = [];
660
+ for (let i = stack.length - 1; i >= 0; i--) {
661
+ const snapshot = stack[i];
662
+ if (snapshot && snapshot.turn === turn) {
663
+ matches.push(snapshot);
664
+ stack.splice(i, 1);
665
+ }
666
+ }
667
+ await removeFromStore(matches.map((s) => s.id));
668
+ const messages = [];
669
+ for (const snapshot of matches) {
670
+ messages.push(await revert(snapshot));
671
+ }
672
+ return messages;
673
+ }
674
+ async function rewindToSnapshot(id) {
675
+ if (!stack.some((snapshot) => snapshot.id === id)) return null;
676
+ const matches = [];
677
+ for (let i = stack.length - 1; i >= 0; i--) {
678
+ const snapshot = stack[i];
679
+ if (snapshot && snapshot.id >= id) {
680
+ matches.push(snapshot);
681
+ stack.splice(i, 1);
682
+ }
683
+ }
684
+ await removeFromStore(matches.map((s) => s.id));
685
+ const reverted = [];
686
+ let messageIndex = null;
687
+ for (const snapshot of matches) {
688
+ reverted.push(await revert(snapshot));
689
+ if (snapshot.messageIndex >= 0) {
690
+ messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
691
+ }
692
+ }
693
+ return { reverted, messageIndex };
694
+ }
695
+
696
+ // src/agent/project-memory.ts
697
+ import { readFileSync, statSync } from "fs";
698
+ import path3 from "path";
699
+ var PROJECT_MEMORY_FILE = "AGENTS.md";
700
+ var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
701
+ var cache = /* @__PURE__ */ new Map();
702
+ function readProjectMemory(cwd) {
703
+ const file = path3.join(cwd, PROJECT_MEMORY_FILE);
704
+ let mtimeMs;
705
+ try {
706
+ mtimeMs = statSync(file).mtimeMs;
707
+ } catch {
708
+ return null;
709
+ }
710
+ const hit = cache.get(file);
711
+ if (hit && hit.mtimeMs === mtimeMs) return hit.block;
712
+ let block = null;
713
+ try {
714
+ let body = readFileSync(file, "utf8").trim();
715
+ if (body) {
716
+ if (body.length > PROJECT_MEMORY_MAX_CHARS) {
717
+ body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
718
+
719
+ [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
720
+ }
721
+ block = `# Project instructions (AGENTS.md)
722
+ ${body}`;
723
+ }
724
+ } catch {
725
+ block = null;
726
+ }
727
+ cache.set(file, { mtimeMs, block });
728
+ return block;
729
+ }
730
+
731
+ // src/agent/skills.ts
732
+ import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
733
+ import path5 from "path";
734
+ import { z } from "zod";
735
+
736
+ // src/config/paths.ts
737
+ import os from "os";
738
+ import path4 from "path";
739
+ function starHome() {
740
+ return process.env.STAR_HOME ?? path4.join(os.homedir(), ".star-cli");
741
+ }
742
+ function globalConfigPath() {
743
+ return path4.join(starHome(), "config.toml");
744
+ }
745
+ function sessionsDir() {
746
+ return path4.join(starHome(), "sessions");
747
+ }
748
+ function projectConfigPath(cwd) {
749
+ return path4.join(cwd, ".star", "config.toml");
750
+ }
751
+
752
+ // src/agent/skills.ts
753
+ var SKILL_FILE = "SKILL.md";
754
+ var SKILL_MAX_CHARS = 32 * 1024;
755
+ var NAME_PATTERN = /^[a-z0-9-]+$/;
756
+ function parseFrontmatter(raw) {
757
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
758
+ if (!match) return null;
759
+ const meta = {};
760
+ for (const line of (match[1] ?? "").split(/\r?\n/)) {
761
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
762
+ if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
763
+ }
764
+ return { meta, body: raw.slice(match[0].length).trim() };
765
+ }
766
+ var cache2 = /* @__PURE__ */ new Map();
767
+ function parseSkillFile(filePath, dirName, scope) {
768
+ let mtimeMs;
769
+ try {
770
+ mtimeMs = statSync2(filePath).mtimeMs;
771
+ } catch {
772
+ return null;
773
+ }
774
+ const hit = cache2.get(filePath);
775
+ if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
776
+ let skill = null;
777
+ try {
778
+ const parsed = parseFrontmatter(readFileSync2(filePath, "utf8"));
779
+ const name = parsed?.meta.name || dirName;
780
+ const description = parsed?.meta.description;
781
+ if (parsed && NAME_PATTERN.test(name) && description) {
782
+ skill = { name, description, filePath, dir: path5.dirname(filePath), scope };
783
+ }
784
+ } catch {
785
+ skill = null;
786
+ }
787
+ cache2.set(filePath, { mtimeMs, skill });
788
+ return skill;
789
+ }
790
+ function scanDir(base, scope, into) {
791
+ let entries;
792
+ try {
793
+ entries = readdirSync(base);
794
+ } catch {
795
+ return;
796
+ }
797
+ for (const entry of entries.sort()) {
798
+ if (!NAME_PATTERN.test(entry)) continue;
799
+ const parsed = parseSkillFile(path5.join(base, entry, SKILL_FILE), entry, scope);
800
+ if (parsed) into.set(parsed.name, parsed);
801
+ }
802
+ }
803
+ function discoverSkills(cwd, home = starHome()) {
804
+ const byName = /* @__PURE__ */ new Map();
805
+ scanDir(path5.join(home, "skills"), "user", byName);
806
+ scanDir(path5.join(cwd, ".star", "skills"), "project", byName);
807
+ return [...byName.values()];
808
+ }
809
+ function formatSkillsBlock(skills) {
810
+ const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
811
+ return [
812
+ "# Available skills",
813
+ "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
814
+ ...lines
815
+ ].join("\n");
816
+ }
817
+ function loadSkillBody(skill) {
818
+ let parsed = null;
819
+ try {
820
+ parsed = parseFrontmatter(readFileSync2(skill.filePath, "utf8"));
821
+ } catch {
822
+ return null;
823
+ }
824
+ if (!parsed?.body) return null;
825
+ if (parsed.body.length > SKILL_MAX_CHARS) {
826
+ return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
827
+
828
+ [skill truncated to ${SKILL_MAX_CHARS} characters]`;
829
+ }
830
+ return parsed.body;
831
+ }
832
+ function createSkillTool(deps) {
833
+ return {
834
+ name: "skill",
835
+ description: "Load the full instructions of a skill by name. Available skills are listed in the system prompt when present; call this before starting work that matches one of them.",
836
+ permission: "read",
837
+ parameters: z.object({
838
+ name: z.string().describe("Name of the skill to load, as listed in the system prompt.")
839
+ }),
840
+ async execute(args) {
841
+ const skills = discoverSkills(deps.cwd, deps.home);
842
+ const skill = skills.find((s) => s.name === args.name);
843
+ if (!skill) {
844
+ const available = skills.map((s) => s.name).join(", ") || "none";
845
+ return {
846
+ content: `Unknown skill: "${args.name}". Available skills: ${available}`,
847
+ isError: true
848
+ };
849
+ }
850
+ const body = loadSkillBody(skill);
851
+ if (!body) {
852
+ return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
853
+ }
854
+ return {
855
+ content: [
856
+ `# Skill: ${skill.name}`,
857
+ "",
858
+ body,
859
+ "",
860
+ `Base directory for relative file references in this skill: ${skill.dir}`
861
+ ].join("\n")
862
+ };
863
+ }
864
+ };
865
+ }
866
+
867
+ // src/agent/subagent.ts
868
+ import { z as z12 } from "zod";
869
+
870
+ // src/tools/bash.ts
871
+ import { spawn as spawn3 } from "child_process";
872
+ import { existsSync } from "fs";
873
+ import path6 from "path";
874
+ import { z as z2 } from "zod";
875
+
876
+ // src/tasks/manager.ts
877
+ import { spawn as spawn2 } from "child_process";
878
+ import { EventEmitter } from "events";
879
+ var MAX_TASK_OUTPUT = 1e5;
880
+ var DEFAULT_TASK_TIMEOUT = 3600;
881
+ var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
882
+ var TaskManager = class extends EventEmitter {
883
+ records = /* @__PURE__ */ new Map();
884
+ seq = 0;
885
+ start(opts) {
886
+ const id = `task-${++this.seq}`;
887
+ const spec = resolveShell();
888
+ const child = spawn2(spec.shell, spec.wrap(opts.command), {
889
+ cwd: opts.cwd,
890
+ windowsHide: true
891
+ });
892
+ const rec = {
893
+ id,
894
+ command: opts.command,
895
+ description: opts.description,
896
+ status: "running",
897
+ startedAt: Date.now(),
898
+ output: "",
899
+ truncated: false,
900
+ child
901
+ };
902
+ this.records.set(id, rec);
903
+ const append = (d) => {
904
+ rec.output += d.toString("utf8");
905
+ if (rec.output.length > MAX_TASK_OUTPUT) {
906
+ rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
907
+ rec.truncated = true;
908
+ }
909
+ };
910
+ child.stdout.on("data", append);
911
+ child.stderr.on("data", append);
912
+ child.on("error", (err) => {
913
+ append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
914
+ `));
915
+ if (rec.status === "running") {
916
+ this.finish(rec, "failed", null);
917
+ }
918
+ });
919
+ child.on("close", (code) => {
920
+ if (rec.status !== "running") return;
921
+ this.finish(rec, code === 0 ? "completed" : "failed", code);
922
+ });
923
+ const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
924
+ rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
925
+ rec.timer.unref();
926
+ this.emitUpdate(rec);
927
+ return this.snapshot(rec);
928
+ }
929
+ get(id) {
930
+ const rec = this.records.get(id);
931
+ return rec ? this.snapshot(rec) : void 0;
932
+ }
933
+ list() {
934
+ return [...this.records.values()].map((rec) => this.snapshot(rec));
935
+ }
936
+ runningCount() {
937
+ let count = 0;
938
+ for (const rec of this.records.values()) {
939
+ if (rec.status === "running") count += 1;
940
+ }
941
+ return count;
942
+ }
943
+ kill(id) {
944
+ const rec = this.records.get(id);
945
+ if (!rec || rec.status !== "running") return void 0;
946
+ this.terminate(rec, "stopped");
947
+ return this.snapshot(rec);
948
+ }
949
+ cleanup() {
950
+ const killed = [];
951
+ for (const rec of this.records.values()) {
952
+ if (rec.status === "running") {
953
+ this.terminate(rec, "stopped");
954
+ killed.push(this.snapshot(rec));
955
+ }
956
+ }
957
+ return killed;
958
+ }
959
+ terminate(rec, status) {
960
+ killTree2(rec.child);
961
+ this.finish(rec, status, null);
962
+ }
963
+ finish(rec, status, exitCode) {
964
+ if (rec.timer) clearTimeout(rec.timer);
965
+ rec.status = status;
966
+ rec.exitCode = exitCode;
967
+ rec.endedAt = Date.now();
968
+ this.emitUpdate(rec);
969
+ }
970
+ emitUpdate(rec) {
971
+ this.emit("update", this.snapshot(rec));
972
+ }
973
+ snapshot(rec) {
974
+ return {
975
+ id: rec.id,
976
+ command: rec.command,
977
+ description: rec.description,
978
+ status: rec.status,
979
+ startedAt: rec.startedAt,
980
+ endedAt: rec.endedAt,
981
+ exitCode: rec.exitCode,
982
+ output: rec.truncated ? TRUNCATED_PREFIX + rec.output : rec.output
983
+ };
984
+ }
985
+ };
986
+ var defaultTaskManager = new TaskManager();
987
+
988
+ // src/tools/bash.ts
989
+ var MAX_OUTPUT = 3e4;
990
+ var DEFAULT_TIMEOUT = 120;
991
+ var MAX_TIMEOUT = 600;
992
+ function findOnPath(exe, exclude) {
993
+ for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
994
+ if (!dir || exclude?.(dir)) {
995
+ continue;
996
+ }
997
+ const full = path6.join(dir, exe);
998
+ if (existsSync(full)) {
999
+ return full;
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+ function resolveShell() {
1005
+ if (process.platform !== "win32") {
1006
+ return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
1007
+ }
1008
+ const isWslStub = (dir) => /\\(system32|windowsapps)\\?$/i.test(dir.trim());
1009
+ const fromPath = findOnPath("bash.exe", isWslStub);
1010
+ if (fromPath) {
1011
+ return { shell: fromPath, wrap: (c) => ["-c", c], label: "bash" };
1012
+ }
1013
+ const gitExe = findOnPath("git.exe");
1014
+ if (gitExe) {
1015
+ const sibling = path6.join(path6.dirname(path6.dirname(gitExe)), "bin", "bash.exe");
1016
+ if (existsSync(sibling)) {
1017
+ return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1018
+ }
1019
+ }
1020
+ for (const candidate of [
1021
+ "C:\\Program Files\\Git\\bin\\bash.exe",
1022
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1023
+ "D:\\Git\\bin\\bash.exe"
1024
+ ]) {
1025
+ if (existsSync(candidate)) {
1026
+ return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1027
+ }
1028
+ }
1029
+ const comspec = process.env.ComSpec ?? "cmd.exe";
1030
+ return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
1031
+ }
1032
+ var schema = z2.object({
1033
+ command: z2.string().describe("Shell command to execute"),
1034
+ timeout: z2.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1035
+ description: z2.string().optional().describe("Short description of what the command does"),
1036
+ run_in_background: z2.boolean().optional().describe(
1037
+ "Run the command in the background and return a task id immediately (default false). Use task_output/task_kill to inspect or stop it."
1038
+ )
1039
+ });
1040
+ function killTree2(child) {
1041
+ if (process.platform === "win32" && child.pid) {
1042
+ spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1043
+ windowsHide: true,
1044
+ stdio: "ignore"
1045
+ }).unref();
1046
+ return;
1047
+ }
1048
+ child.kill("SIGKILL");
1049
+ }
1050
+ function truncateMiddle(s) {
1051
+ if (s.length <= MAX_OUTPUT) {
1052
+ return s;
1053
+ }
1054
+ const half = Math.floor(MAX_OUTPUT / 2);
1055
+ const head = s.slice(0, half);
1056
+ const tail = s.slice(-half);
1057
+ return `${head}
1058
+ ... [${s.length - MAX_OUTPUT} characters truncated] ...
1059
+ ${tail}`;
1060
+ }
1061
+ var bashTool = {
1062
+ name: "bash",
1063
+ description: "Execute a shell command (Git Bash on Windows when available, otherwise cmd; sh elsewhere). Stdout and stderr are merged. Output is truncated to 30000 characters. Set run_in_background for long-running commands; it returns a task id for task_list/task_output/task_kill.",
1064
+ permission: "exec",
1065
+ parameters: schema,
1066
+ execute(args, ctx) {
1067
+ if (args.run_in_background) {
1068
+ const task = defaultTaskManager.start({
1069
+ command: args.command,
1070
+ description: args.description,
1071
+ cwd: ctx.cwd,
1072
+ timeoutSeconds: args.timeout
1073
+ });
1074
+ return Promise.resolve({
1075
+ content: `Background task started: ${task.id}
1076
+ command: ${args.command}
1077
+ description: ${args.description ?? "(none)"}`
1078
+ });
1079
+ }
1080
+ const timeoutSeconds = Math.min(args.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
1081
+ const spec = resolveShell();
1082
+ return new Promise((resolve) => {
1083
+ const child = spawn3(spec.shell, spec.wrap(args.command), {
1084
+ cwd: ctx.cwd,
1085
+ windowsHide: true
1086
+ });
1087
+ let output = "";
1088
+ let settled = false;
1089
+ const finish = (result) => {
1090
+ if (settled) {
1091
+ return;
1092
+ }
1093
+ settled = true;
1094
+ clearTimeout(timer);
1095
+ ctx.abortSignal?.removeEventListener("abort", onAbort);
1096
+ resolve(result);
1097
+ };
1098
+ const onAbort = () => {
1099
+ killTree2(child);
1100
+ finish({ content: `${truncateMiddle(output)}
1101
+ Command aborted`, isError: true });
1102
+ };
1103
+ const timer = setTimeout(() => {
1104
+ killTree2(child);
1105
+ finish({
1106
+ content: `${truncateMiddle(output)}
1107
+ Command timed out after ${timeoutSeconds}s`,
1108
+ isError: true
1109
+ });
1110
+ }, timeoutSeconds * 1e3);
1111
+ child.stdout.on("data", (d) => {
1112
+ output += d.toString("utf8");
1113
+ });
1114
+ child.stderr.on("data", (d) => {
1115
+ output += d.toString("utf8");
1116
+ });
1117
+ child.on("error", (err) => {
1118
+ finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
1119
+ });
1120
+ ctx.abortSignal?.addEventListener("abort", onAbort);
1121
+ child.on("close", (code) => {
1122
+ const trimmed = output.replace(/\s+$/, "");
1123
+ if (code === 0) {
1124
+ finish({ content: truncateMiddle(trimmed) || "(no output)" });
1125
+ return;
1126
+ }
1127
+ const body = truncateMiddle(trimmed);
1128
+ finish({
1129
+ content: `${body}${body ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1130
+ isError: true
1131
+ });
1132
+ });
1133
+ });
1134
+ }
1135
+ };
1136
+
1137
+ // src/tools/fs/edit.ts
1138
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1139
+ import path7 from "path";
1140
+ import { z as z3 } from "zod";
1141
+ var schema2 = z3.object({
1142
+ path: z3.string().describe("File path, absolute or relative to the working directory"),
1143
+ old_string: z3.string().min(1).describe("Exact text to replace"),
1144
+ new_string: z3.string().describe("Replacement text"),
1145
+ replace_all: z3.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
1146
+ });
1147
+ var editFileTool = {
1148
+ name: "edit_file",
1149
+ description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set.",
1150
+ permission: "write",
1151
+ parameters: schema2,
1152
+ async execute(args, ctx) {
1153
+ const filePath = path7.resolve(ctx.cwd, args.path);
1154
+ let content;
1155
+ try {
1156
+ content = await readFile2(filePath, "utf8");
1157
+ } catch (err) {
1158
+ return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1159
+ }
1160
+ let count = 0;
1161
+ let idx = content.indexOf(args.old_string);
1162
+ while (idx !== -1) {
1163
+ count += 1;
1164
+ idx = content.indexOf(args.old_string, idx + args.old_string.length);
1165
+ }
1166
+ if (count === 0) {
1167
+ return { content: `old_string not found in ${args.path}`, isError: true };
1168
+ }
1169
+ if (count > 1 && !args.replace_all) {
1170
+ return {
1171
+ content: `old_string matches ${count} locations in ${args.path}; provide more surrounding context or set replace_all`,
1172
+ isError: true
1173
+ };
1174
+ }
1175
+ const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
1176
+ try {
1177
+ await writeFile2(filePath, updated, "utf8");
1178
+ } catch (err) {
1179
+ return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1180
+ }
1181
+ await pushSnapshot({
1182
+ id: nextSnapshotId(),
1183
+ path: filePath,
1184
+ existed: true,
1185
+ content,
1186
+ toolName: "edit_file",
1187
+ timestamp: Date.now(),
1188
+ turn: currentTurnSeq(),
1189
+ messageIndex: currentTurnMessageIndex()
1190
+ });
1191
+ return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
1192
+ }
1193
+ };
1194
+
1195
+ // src/tools/fs/glob.ts
1196
+ import path9 from "path";
1197
+ import { z as z4 } from "zod";
1198
+
1199
+ // src/tools/fs/util.ts
1200
+ import { readdir, stat } from "fs/promises";
1201
+ import path8 from "path";
1202
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
1203
+ async function walkFiles(root, skipDirs = SKIP_DIRS) {
1204
+ const out = [];
1205
+ async function walk(dir, relBase) {
1206
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
1207
+ if (!entries) {
1208
+ return;
1209
+ }
1210
+ for (const entry of entries) {
1211
+ const abs = path8.join(dir, entry.name);
1212
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1213
+ if (entry.isDirectory()) {
1214
+ if (!skipDirs.has(entry.name)) {
1215
+ await walk(abs, rel);
1216
+ }
1217
+ } else if (entry.isFile()) {
1218
+ try {
1219
+ const st = await stat(abs);
1220
+ out.push({ abs, rel, mtimeMs: st.mtimeMs });
1221
+ } catch {
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+ await walk(root, "");
1227
+ return out;
1228
+ }
1229
+ var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
1230
+ function globToRegExp(pattern) {
1231
+ let re = "";
1232
+ let i = 0;
1233
+ while (i < pattern.length) {
1234
+ const c = pattern.charAt(i);
1235
+ if (c === "*") {
1236
+ if (pattern.charAt(i + 1) === "*") {
1237
+ i += 2;
1238
+ if (pattern.charAt(i) === "/") {
1239
+ i += 1;
1240
+ re += "(?:[^/]+/)*";
1241
+ } else {
1242
+ re += ".*";
1243
+ }
1244
+ } else {
1245
+ re += "[^/]*";
1246
+ i += 1;
1247
+ }
1248
+ } else if (c === "?") {
1249
+ re += "[^/]";
1250
+ i += 1;
1251
+ } else {
1252
+ re += c.replace(REGEX_SPECIALS, "\\$&");
1253
+ i += 1;
1254
+ }
1255
+ }
1256
+ return new RegExp(`^${re}$`);
1257
+ }
1258
+ function matchesGlob(pattern, relPath) {
1259
+ const normalized = pattern.replace(/\\/g, "/");
1260
+ if (!normalized.includes("/")) {
1261
+ const base = relPath.split("/").pop() ?? relPath;
1262
+ return globToRegExp(normalized).test(base);
1263
+ }
1264
+ return globToRegExp(normalized).test(relPath);
1265
+ }
1266
+ function isSensitivePath2(filePath) {
1267
+ const base = path8.basename(filePath);
1268
+ if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
1269
+ return false;
1270
+ }
1271
+ if (base === ".env" || base.startsWith(".env.")) {
1272
+ return true;
1273
+ }
1274
+ if (base === "id_rsa" || base.endsWith(".pem")) {
1275
+ return true;
1276
+ }
1277
+ return false;
1278
+ }
1279
+
1280
+ // src/tools/fs/glob.ts
1281
+ var MAX_RESULTS = 100;
1282
+ var schema3 = z4.object({
1283
+ pattern: z4.string().describe(
1284
+ "Glob pattern, e.g. 'src/**/*.ts'. Supports **, * and ?. A bare pattern like '*.ts' matches basenames recursively."
1285
+ ),
1286
+ path: z4.string().optional().describe("Directory to search, defaults to the working directory")
1287
+ });
1288
+ var globTool = {
1289
+ name: "glob",
1290
+ description: "Find files by glob pattern. Skips node_modules, .git and dist. Returns paths sorted by modification time, newest first, up to 100 results.",
1291
+ permission: "read",
1292
+ parameters: schema3,
1293
+ async execute(args, ctx) {
1294
+ const root = path9.resolve(ctx.cwd, args.path ?? ".");
1295
+ const files = await walkFiles(root);
1296
+ const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
1297
+ if (matched.length === 0) {
1298
+ return { content: `No files matched pattern: ${args.pattern}` };
1299
+ }
1300
+ return { content: matched.map((f) => f.rel).join("\n") };
1301
+ }
1302
+ };
1303
+
1304
+ // src/tools/fs/grep.ts
1305
+ import { readFile as readFile3, stat as stat2 } from "fs/promises";
1306
+ import path10 from "path";
1307
+ import { z as z5 } from "zod";
1308
+ var MAX_MATCHES = 250;
1309
+ var schema4 = z5.object({
1310
+ pattern: z5.string().describe("Regular expression to search for"),
1311
+ path: z5.string().optional().describe("File or directory to search, defaults to the working directory"),
1312
+ glob: z5.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1313
+ ignoreCase: z5.boolean().optional().describe("Case-insensitive matching")
1314
+ });
1315
+ var grepTool = {
1316
+ name: "grep",
1317
+ description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, node_modules and .git.",
1318
+ permission: "read",
1319
+ parameters: schema4,
1320
+ async execute(args, ctx) {
1321
+ let re;
1322
+ try {
1323
+ re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
1324
+ } catch (err) {
1325
+ return { content: `Invalid regular expression: ${err.message}`, isError: true };
1326
+ }
1327
+ const root = path10.resolve(ctx.cwd, args.path ?? ".");
1328
+ let files;
1329
+ try {
1330
+ const st = await stat2(root);
1331
+ if (st.isFile()) {
1332
+ files = [{ abs: root, rel: path10.basename(root), mtimeMs: st.mtimeMs }];
1333
+ } else {
1334
+ files = await walkFiles(root);
1335
+ }
1336
+ } catch {
1337
+ return { content: `Path not found: ${args.path ?? "."}`, isError: true };
1338
+ }
1339
+ if (args.glob) {
1340
+ const glob = args.glob;
1341
+ files = files.filter((f) => matchesGlob(glob, f.rel));
1342
+ }
1343
+ const out = [];
1344
+ let truncated = false;
1345
+ outer: for (const file of files) {
1346
+ let buf;
1347
+ try {
1348
+ buf = await readFile3(file.abs);
1349
+ } catch {
1350
+ continue;
1351
+ }
1352
+ if (buf.includes(0)) {
1353
+ continue;
1354
+ }
1355
+ const lines = buf.toString("utf8").split("\n");
1356
+ for (let i = 0; i < lines.length; i++) {
1357
+ const line = lines[i] ?? "";
1358
+ if (re.test(line)) {
1359
+ out.push(`${file.rel}:${i + 1}:${line}`);
1360
+ if (out.length >= MAX_MATCHES) {
1361
+ truncated = true;
1362
+ break outer;
1363
+ }
1364
+ }
1365
+ }
1366
+ }
1367
+ if (out.length === 0) {
1368
+ return { content: `No matches for pattern: ${args.pattern}` };
1369
+ }
1370
+ if (truncated) {
1371
+ out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
1372
+ }
1373
+ return { content: out.join("\n") };
1374
+ }
1375
+ };
1376
+
1377
+ // src/tools/fs/read.ts
1378
+ import { readFile as readFile4, stat as stat3 } from "fs/promises";
1379
+ import path11 from "path";
1380
+ import { z as z6 } from "zod";
1381
+ var DEFAULT_LIMIT = 2e3;
1382
+ var MAX_CHARS = 100 * 1024;
1383
+ var schema5 = z6.object({
1384
+ path: z6.string().describe("File path, absolute or relative to the working directory"),
1385
+ offset: z6.number().int().positive().optional().describe("1-based line number to start reading from"),
1386
+ limit: z6.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1387
+ });
1388
+ var readFileTool = {
1389
+ name: "read_file",
1390
+ description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default.",
1391
+ permission: "read",
1392
+ parameters: schema5,
1393
+ async execute(args, ctx) {
1394
+ const filePath = path11.resolve(ctx.cwd, args.path);
1395
+ if (isSensitivePath2(filePath)) {
1396
+ return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1397
+ }
1398
+ const st = await stat3(filePath).catch(() => null);
1399
+ if (!st) {
1400
+ return { content: `File not found: ${args.path}`, isError: true };
1401
+ }
1402
+ if (st.isDirectory()) {
1403
+ return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1404
+ }
1405
+ const raw = await readFile4(filePath, "utf8");
1406
+ const lines = raw.split("\n");
1407
+ const start = args.offset ?? 1;
1408
+ const limit = args.limit ?? DEFAULT_LIMIT;
1409
+ const end = Math.min(lines.length, start - 1 + limit);
1410
+ const out = [];
1411
+ let size = 0;
1412
+ let truncatedBySize = false;
1413
+ for (let i = start - 1; i < end; i++) {
1414
+ const line = lines[i];
1415
+ if (line === void 0) {
1416
+ break;
1417
+ }
1418
+ const numbered = `${i + 1} ${line}`;
1419
+ if (size + numbered.length > MAX_CHARS) {
1420
+ truncatedBySize = true;
1421
+ break;
1422
+ }
1423
+ out.push(numbered);
1424
+ size += numbered.length;
1425
+ }
1426
+ const lastShown = start - 1 + out.length;
1427
+ if (truncatedBySize) {
1428
+ out.push(
1429
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${lines.length})`
1430
+ );
1431
+ } else if (end < lines.length) {
1432
+ out.push(`... (truncated: showing lines ${start}-${end} of ${lines.length})`);
1433
+ }
1434
+ return { content: out.join("\n") };
1435
+ }
1436
+ };
1437
+
1438
+ // src/tools/fs/write.ts
1439
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1440
+ import path12 from "path";
1441
+ import { z as z7 } from "zod";
1442
+ var schema6 = z7.object({
1443
+ path: z7.string().describe("File path, absolute or relative to the working directory"),
1444
+ content: z7.string().describe("Full content to write to the file")
1445
+ });
1446
+ var writeFileTool = {
1447
+ name: "write_file",
1448
+ description: "Write content to a file, creating parent directories as needed. Overwrites existing files.",
1449
+ permission: "write",
1450
+ parameters: schema6,
1451
+ async execute(args, ctx) {
1452
+ const filePath = path12.resolve(ctx.cwd, args.path);
1453
+ const snapshot = await captureSnapshot(filePath, "write_file");
1454
+ try {
1455
+ await mkdir2(path12.dirname(filePath), { recursive: true });
1456
+ await writeFile3(filePath, args.content, "utf8");
1457
+ } catch (err) {
1458
+ return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1459
+ }
1460
+ await pushSnapshot(snapshot);
1461
+ return { content: `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}` };
1462
+ }
1463
+ };
1464
+
1465
+ // src/tools/tasks.ts
1466
+ import { z as z8 } from "zod";
1467
+
1468
+ // src/tasks/format.ts
1469
+ function formatDuration(startedAt, endedAt) {
1470
+ const seconds = Math.max(0, Math.round(((endedAt ?? Date.now()) - startedAt) / 1e3));
1471
+ if (seconds < 60) return `${seconds}s`;
1472
+ const minutes = Math.floor(seconds / 60);
1473
+ if (minutes < 60) return `${minutes}m${seconds % 60}s`;
1474
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
1475
+ }
1476
+ function formatStatus(task) {
1477
+ const duration = formatDuration(task.startedAt, task.endedAt);
1478
+ if (task.status === "running") return `running ${duration}`;
1479
+ const exit = task.exitCode === null || task.exitCode === void 0 ? "" : `, exit ${task.exitCode}`;
1480
+ return `${task.status}${exit} after ${duration}`;
1481
+ }
1482
+ function formatTaskLine(task) {
1483
+ const label = task.description ? `${task.description} (${task.command})` : task.command;
1484
+ return `${task.id} [${formatStatus(task)}] ${label}`;
1485
+ }
1486
+ function formatTaskList(tasks) {
1487
+ if (tasks.length === 0) return "No background tasks.";
1488
+ return `Background tasks:
1489
+ ${tasks.map(formatTaskLine).join("\n")}`;
1490
+ }
1491
+ function formatTaskStarted(task) {
1492
+ const label = task.description ? `${task.description} (${task.command})` : task.command;
1493
+ return `Background task ${task.id} started: ${label}`;
1494
+ }
1495
+ function formatTaskFinished(task) {
1496
+ const exit = task.exitCode === null || task.exitCode === void 0 ? "" : ` (exit ${task.exitCode})`;
1497
+ switch (task.status) {
1498
+ case "completed":
1499
+ return `Background task ${task.id} finished${exit}: ${task.command}`;
1500
+ case "failed":
1501
+ return `Background task ${task.id} failed${exit}: ${task.command}`;
1502
+ case "timed_out":
1503
+ return `Background task ${task.id} timed out: ${task.command}`;
1504
+ case "stopped":
1505
+ return `Background task ${task.id} stopped: ${task.command}`;
1506
+ default:
1507
+ return `Background task ${task.id}: ${task.command}`;
1508
+ }
1509
+ }
1510
+
1511
+ // src/tools/tasks.ts
1512
+ var idSchema = z8.object({
1513
+ id: z8.string().describe("Task id, e.g. task-1")
1514
+ });
1515
+ var taskListTool = {
1516
+ name: "task_list",
1517
+ description: "List background shell tasks with status, runtime, and exit code.",
1518
+ permission: "read",
1519
+ parameters: z8.object({}),
1520
+ execute() {
1521
+ return Promise.resolve({ content: formatTaskList(defaultTaskManager.list()) });
1522
+ }
1523
+ };
1524
+ var taskOutputTool = {
1525
+ name: "task_output",
1526
+ description: "Read the current output of a background shell task (works while still running).",
1527
+ permission: "read",
1528
+ parameters: idSchema,
1529
+ execute(args) {
1530
+ const task = defaultTaskManager.get(args.id);
1531
+ if (!task) {
1532
+ return Promise.resolve({ content: `Unknown task: ${args.id}`, isError: true });
1533
+ }
1534
+ const body = task.output.replace(/\s+$/, "") || "(no output yet)";
1535
+ return Promise.resolve({ content: `${formatTaskLine(task)}
1536
+ ${body}` });
1537
+ }
1538
+ };
1539
+ var taskKillTool = {
1540
+ name: "task_kill",
1541
+ description: "Stop a running background shell task (kills the whole process tree).",
1542
+ permission: "exec",
1543
+ parameters: idSchema,
1544
+ execute(args) {
1545
+ const task = defaultTaskManager.get(args.id);
1546
+ if (!task) {
1547
+ return Promise.resolve({ content: `Unknown task: ${args.id}`, isError: true });
1548
+ }
1549
+ if (task.status !== "running") {
1550
+ return Promise.resolve({
1551
+ content: `Task ${args.id} is not running (status: ${task.status}).`,
1552
+ isError: true
1553
+ });
1554
+ }
1555
+ defaultTaskManager.kill(args.id);
1556
+ return Promise.resolve({ content: `Task ${args.id} stopped.` });
1557
+ }
1558
+ };
1559
+
1560
+ // src/tools/todo.ts
1561
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1562
+ import path13 from "path";
1563
+ import { z as z9 } from "zod";
1564
+ var todoItemSchema = z9.object({
1565
+ id: z9.number().int(),
1566
+ title: z9.string(),
1567
+ status: z9.enum(["pending", "in_progress", "done"])
1568
+ });
1569
+ var writeSchema = z9.object({
1570
+ todos: z9.array(todoItemSchema).describe("The full todo list, replacing the current one")
1571
+ });
1572
+ var readSchema = z9.object({});
1573
+ function fileFor(cwd) {
1574
+ return path13.join(cwd, ".star", "todos.json");
1575
+ }
1576
+ var TodoStore = class {
1577
+ items = /* @__PURE__ */ new Map();
1578
+ list() {
1579
+ return [...this.items.values()];
1580
+ }
1581
+ replace(items) {
1582
+ this.items.clear();
1583
+ for (const item of items) {
1584
+ this.items.set(item.id, item);
1585
+ }
1586
+ }
1587
+ async load(cwd) {
1588
+ let raw;
1589
+ try {
1590
+ raw = await readFile5(fileFor(cwd), "utf8");
1591
+ } catch {
1592
+ return;
1593
+ }
1594
+ try {
1595
+ const parsed = JSON.parse(raw);
1596
+ if (Array.isArray(parsed)) {
1597
+ this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
1598
+ }
1599
+ } catch {
1600
+ }
1601
+ }
1602
+ async save(cwd) {
1603
+ await mkdir3(path13.join(cwd, ".star"), { recursive: true });
1604
+ await writeFile4(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1605
+ `);
1606
+ }
1607
+ };
1608
+ var SYMBOLS = {
1609
+ pending: "[ ]",
1610
+ in_progress: "[~]",
1611
+ done: "[x]"
1612
+ };
1613
+ function formatTodos(items) {
1614
+ if (items.length === 0) {
1615
+ return "No todos.";
1616
+ }
1617
+ const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
1618
+ const width = Math.max(...lines.map((l) => l.length));
1619
+ return lines.map((line, i) => {
1620
+ const status = items[i]?.status;
1621
+ return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
1622
+ }).join("\n");
1623
+ }
1624
+ var defaultStore = new TodoStore();
1625
+ function createTodoTools(store = defaultStore) {
1626
+ const todoWrite = {
1627
+ name: "todo_write",
1628
+ description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
1629
+ permission: "read",
1630
+ parameters: writeSchema,
1631
+ async execute(args, ctx) {
1632
+ const parsed = writeSchema.safeParse(args);
1633
+ if (!parsed.success) {
1634
+ return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
1635
+ }
1636
+ await store.load(ctx.cwd);
1637
+ store.replace(parsed.data.todos);
1638
+ await store.save(ctx.cwd);
1639
+ return { content: formatTodos(store.list()) };
1640
+ }
1641
+ };
1642
+ const todoRead = {
1643
+ name: "todo_read",
1644
+ description: "Read the current todo list.",
1645
+ permission: "read",
1646
+ parameters: readSchema,
1647
+ async execute(_args, ctx) {
1648
+ await store.load(ctx.cwd);
1649
+ return { content: formatTodos(store.list()) };
1650
+ }
1651
+ };
1652
+ return [todoWrite, todoRead];
1653
+ }
1654
+
1655
+ // src/tools/web/fetch.ts
1656
+ import { z as z10 } from "zod";
1657
+ var DEFAULT_MAX_CHARS = 2e4;
1658
+ var MAX_BYTES = 2 * 1024 * 1024;
1659
+ var TIMEOUT_MS = 3e4;
1660
+ var schema7 = z10.object({
1661
+ url: z10.string().describe("The http(s) URL to fetch"),
1662
+ maxChars: z10.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
1663
+ });
1664
+ function decodeEntities(s) {
1665
+ return s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCodePoint(Number.parseInt(n, 16))).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
1666
+ switch (m) {
1667
+ case "&quot;":
1668
+ return '"';
1669
+ case "&lt;":
1670
+ return "<";
1671
+ case "&gt;":
1672
+ return ">";
1673
+ case "&nbsp;":
1674
+ return " ";
1675
+ default:
1676
+ return m;
1677
+ }
1678
+ }).replace(/&amp;/g, "&");
1679
+ }
1680
+ function htmlToText(html) {
1681
+ const noBlocks = html.replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
1682
+ const withBreaks = noBlocks.replace(
1683
+ /<br\s*\/?>|<\/(p|div|li|tr|h[1-6]|section|article|header|footer|blockquote|pre)>/gi,
1684
+ "\n"
1685
+ );
1686
+ const stripped = decodeEntities(withBreaks.replace(/<[^>]+>/g, " "));
1687
+ return stripped.split("\n").map((line) => line.replace(/\s+/g, " ").trim()).join("\n").replace(/\n{3,}/g, "\n\n").trim();
1688
+ }
1689
+ async function readBody(res, maxChars) {
1690
+ if (!res.body) {
1691
+ return "";
1692
+ }
1693
+ const reader = res.body.getReader();
1694
+ const decoder = new TextDecoder("utf-8");
1695
+ let bytes = 0;
1696
+ let text = "";
1697
+ try {
1698
+ for (; ; ) {
1699
+ const { done, value } = await reader.read();
1700
+ if (done) {
1701
+ break;
1702
+ }
1703
+ bytes += value.byteLength;
1704
+ text += decoder.decode(value, { stream: true });
1705
+ if (bytes >= MAX_BYTES || text.length >= maxChars * 2) {
1706
+ break;
1707
+ }
1708
+ }
1709
+ text += decoder.decode();
1710
+ } finally {
1711
+ reader.cancel().catch(() => {
1712
+ });
1713
+ }
1714
+ return text;
1715
+ }
1716
+ function truncate2(s, maxChars) {
1717
+ if (s.length <= maxChars) {
1718
+ return s;
1719
+ }
1720
+ return `${s.slice(0, maxChars)}
1721
+ [truncated, showing first ${maxChars} of ${s.length} chars]`;
1722
+ }
1723
+ var webFetchTool = {
1724
+ name: "web_fetch",
1725
+ description: "Fetch a URL over http(s) and return its contents as readable text. HTML pages are converted to plain text (tags, scripts and styles removed). Output is truncated to maxChars (default 20000).",
1726
+ permission: "read",
1727
+ parameters: schema7,
1728
+ async execute(args, ctx) {
1729
+ let url;
1730
+ try {
1731
+ url = new URL(args.url);
1732
+ } catch {
1733
+ return { content: `ERROR: invalid URL: ${args.url}`, isError: true };
1734
+ }
1735
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1736
+ return {
1737
+ content: `ERROR: only http(s) URLs are supported, got: ${url.protocol}`,
1738
+ isError: true
1739
+ };
1740
+ }
1741
+ const maxChars = args.maxChars ?? DEFAULT_MAX_CHARS;
1742
+ const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS);
1743
+ const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
1744
+ let res;
1745
+ try {
1746
+ res = await fetch(url, { signal, redirect: "follow" });
1747
+ } catch (err) {
1748
+ if (ctx.abortSignal?.aborted) {
1749
+ return { content: "ERROR: request aborted", isError: true };
1750
+ }
1751
+ if (timeoutSignal.aborted) {
1752
+ return { content: `ERROR: request timed out after ${TIMEOUT_MS / 1e3}s`, isError: true };
1753
+ }
1754
+ const message = err instanceof Error ? err.message : String(err);
1755
+ return { content: `ERROR: failed to fetch ${args.url}: ${message}`, isError: true };
1756
+ }
1757
+ if (!res.ok) {
1758
+ await res.body?.cancel().catch(() => {
1759
+ });
1760
+ return {
1761
+ content: `ERROR: HTTP ${res.status} ${res.statusText} for ${args.url}`,
1762
+ isError: true
1763
+ };
1764
+ }
1765
+ const [rawType = ""] = (res.headers.get("content-type") ?? "text/plain").split(";");
1766
+ const contentType = rawType.trim().toLowerCase();
1767
+ const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
1768
+ const isText = contentType.startsWith("text/") || contentType === "application/json" || contentType === "application/xml" || contentType.endsWith("+json") || contentType.endsWith("+xml");
1769
+ if (!isHtml && !isText) {
1770
+ await res.body?.cancel().catch(() => {
1771
+ });
1772
+ return {
1773
+ content: `ERROR: unsupported content type: ${contentType} (${args.url})`,
1774
+ isError: true
1775
+ };
1776
+ }
1777
+ let body;
1778
+ try {
1779
+ body = await readBody(res, maxChars);
1780
+ } catch (err) {
1781
+ const message = err instanceof Error ? err.message : String(err);
1782
+ return { content: `ERROR: failed to read response body: ${message}`, isError: true };
1783
+ }
1784
+ const text = isHtml ? htmlToText(body) : body;
1785
+ return { content: truncate2(text, maxChars) };
1786
+ }
1787
+ };
1788
+
1789
+ // src/tools/web/search.ts
1790
+ import { z as z11 } from "zod";
1791
+ var TIMEOUT_MS2 = 15e3;
1792
+ var DEFAULT_MAX_RESULTS = 5;
1793
+ var MAX_RESULTS_LIMIT = 10;
1794
+ var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
1795
+ 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";
1796
+ var schema8 = z11.object({
1797
+ query: z11.string().min(1).describe("The search query"),
1798
+ maxResults: z11.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
1799
+ `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
1800
+ )
1801
+ });
1802
+ function stripTags(s) {
1803
+ return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
1804
+ }
1805
+ function normalizeUrl(href) {
1806
+ const decoded = decodeEntities(href);
1807
+ const uddg = decoded.match(/[?&]uddg=([^&]+)/);
1808
+ if (uddg?.[1]) {
1809
+ try {
1810
+ return decodeURIComponent(uddg[1]);
1811
+ } catch {
1812
+ return decoded;
1813
+ }
1814
+ }
1815
+ if (decoded.startsWith("//")) {
1816
+ return `https:${decoded}`;
1817
+ }
1818
+ return decoded;
1819
+ }
1820
+ function parseResults(html) {
1821
+ const results = [];
1822
+ const anchorRe = /<a\b[^>]*\bclass\s*=\s*"[^"]*\bresult__a\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi;
1823
+ const anchors = [...html.matchAll(anchorRe)];
1824
+ for (const [i, anchor] of anchors.entries()) {
1825
+ const full = anchor[0];
1826
+ const hrefMatch = full.match(/href\s*=\s*"([^"]*)"/i);
1827
+ if (!hrefMatch?.[1]) {
1828
+ continue;
1829
+ }
1830
+ const url = normalizeUrl(hrefMatch[1]);
1831
+ const title = stripTags(full.replace(/^<a\b[^>]*>/i, "").replace(/<\/a>\s*$/i, ""));
1832
+ const blockStart = (anchor.index ?? 0) + full.length;
1833
+ const blockEnd = i + 1 < anchors.length ? anchors[i + 1]?.index ?? html.length : html.length;
1834
+ const block = html.slice(blockStart, blockEnd);
1835
+ const snippetMatch = block.match(
1836
+ /<(?:a|div)\b[^>]*\bclass\s*=\s*"[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div)>/i
1837
+ );
1838
+ const snippet = snippetMatch?.[1] ? stripTags(snippetMatch[1]) : "";
1839
+ if (title && url) {
1840
+ results.push({ title, url, snippet });
1841
+ }
1842
+ }
1843
+ return results;
1844
+ }
1845
+ var webSearchTool = {
1846
+ name: "web_search",
1847
+ description: "Search the web using DuckDuckGo and return a numbered list of results with title, URL and snippet. No API key required. Use web_fetch to read a result's full page.",
1848
+ permission: "read",
1849
+ parameters: schema8,
1850
+ async execute(args, ctx) {
1851
+ const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
1852
+ const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
1853
+ const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS2);
1854
+ const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
1855
+ let res;
1856
+ try {
1857
+ res = await fetch(url, {
1858
+ signal,
1859
+ redirect: "follow",
1860
+ headers: { "user-agent": USER_AGENT, accept: "text/html" }
1861
+ });
1862
+ } catch (err) {
1863
+ if (ctx.abortSignal?.aborted) {
1864
+ return { content: "ERROR: request aborted", isError: true };
1865
+ }
1866
+ const name = err instanceof Error ? err.name : "";
1867
+ if (timeoutSignal.aborted || name === "TimeoutError") {
1868
+ return { content: `ERROR: search timed out after ${TIMEOUT_MS2 / 1e3}s`, isError: true };
1869
+ }
1870
+ const message = err instanceof Error ? err.message : String(err);
1871
+ return { content: `ERROR: search request failed: ${message}`, isError: true };
1872
+ }
1873
+ if (!res.ok) {
1874
+ await res.body?.cancel().catch(() => {
1875
+ });
1876
+ return {
1877
+ content: `ERROR: HTTP ${res.status} ${res.statusText} from DuckDuckGo`,
1878
+ isError: true
1879
+ };
1880
+ }
1881
+ let html;
1882
+ try {
1883
+ html = await res.text();
1884
+ } catch (err) {
1885
+ const message = err instanceof Error ? err.message : String(err);
1886
+ return { content: `ERROR: failed to read search response: ${message}`, isError: true };
1887
+ }
1888
+ const results = parseResults(html).slice(0, maxResults);
1889
+ if (results.length === 0) {
1890
+ return {
1891
+ content: `ERROR: no results found for "${args.query}" (the response may have been blocked or the page layout changed)`,
1892
+ isError: true
1893
+ };
1894
+ }
1895
+ const lines = results.map(
1896
+ (r, i) => `${i + 1}. ${r.title}
1897
+ ${r.url}${r.snippet ? `
1898
+ ${r.snippet}` : ""}`
1899
+ );
1900
+ return { content: lines.join("\n") };
1901
+ }
1902
+ };
1903
+
1904
+ // src/tools/registry.ts
1905
+ var ToolRegistry = class {
1906
+ tools = /* @__PURE__ */ new Map();
1907
+ constructor() {
1908
+ for (const tool of [
1909
+ readFileTool,
1910
+ writeFileTool,
1911
+ editFileTool,
1912
+ globTool,
1913
+ grepTool,
1914
+ bashTool,
1915
+ webFetchTool,
1916
+ webSearchTool,
1917
+ taskListTool,
1918
+ taskOutputTool,
1919
+ taskKillTool,
1920
+ ...createTodoTools()
1921
+ ]) {
1922
+ this.register(tool);
1923
+ }
1924
+ }
1925
+ register(tool) {
1926
+ this.tools.set(tool.name, tool);
1927
+ }
1928
+ get(name) {
1929
+ return this.tools.get(name);
1930
+ }
1931
+ list() {
1932
+ return [...this.tools.values()];
1933
+ }
1934
+ names() {
1935
+ return [...this.tools.keys()];
1936
+ }
1937
+ };
1938
+
1939
+ // src/tools/index.ts
1940
+ function createDefaultRegistry() {
1941
+ const registry = new ToolRegistry();
1942
+ for (const tool of createTodoTools()) {
1943
+ registry.register(tool);
1944
+ }
1945
+ return registry;
1946
+ }
1947
+
1948
+ // src/agent/subagent.ts
1949
+ var MAX_SUBAGENT_DEPTH = 1;
1950
+ var MAX_REPORT_CHARS = 2e4;
1951
+ var SUBAGENT_PROMPT = "You are a subagent spawned to handle a focused subtask on behalf of the main agent. Work autonomously with the tools available to you, then finish with a concise report of what you found or did. Your final text is returned to the main agent as the tool result \u2014 make it self-contained.";
1952
+ function createSubagentTool(deps) {
1953
+ return {
1954
+ name: "subagent",
1955
+ description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation.",
1956
+ permission: "exec",
1957
+ parameters: z12.object({
1958
+ prompt: z12.string().describe("Complete, self-contained instructions for the subagent."),
1959
+ description: z12.string().optional().describe("Short label for the subtask, shown in the result header.")
1960
+ }),
1961
+ async execute(args, ctx) {
1962
+ const { AgentLoop: AgentLoop2 } = await import("./loop-R3RQCNFT.js");
1963
+ const child = new AgentLoop2({
1964
+ model: deps.model,
1965
+ registry: createDefaultRegistry(),
1966
+ config: deps.config,
1967
+ cwd: ctx.cwd,
1968
+ system: deps.system ? `${deps.system}
1969
+
1970
+ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
1971
+ subagentDepth: deps.depth + 1
1972
+ });
1973
+ const confirmHandler = deps.getConfirmHandler?.();
1974
+ if (confirmHandler) child.confirmHandler = confirmHandler;
1975
+ const signal = ctx.abortSignal ?? new AbortController().signal;
1976
+ let toolCalls = 0;
1977
+ let error = null;
1978
+ try {
1979
+ for await (const event of child.stream(args.prompt, signal)) {
1980
+ if (event.type === "tool-result") {
1981
+ toolCalls++;
1982
+ } else if (event.type === "error") {
1983
+ error = event.error.message;
1984
+ break;
1985
+ }
1986
+ }
1987
+ } catch (e) {
1988
+ error = e instanceof Error ? e.message : String(e);
1989
+ }
1990
+ let report = "";
1991
+ const messages = child.getMessages();
1992
+ for (let i = messages.length - 1; i >= 0; i--) {
1993
+ const message = messages[i];
1994
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
1995
+ const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
1996
+ if (text) {
1997
+ report = text;
1998
+ break;
1999
+ }
2000
+ }
2001
+ if (report.length > MAX_REPORT_CHARS) {
2002
+ report = `${report.slice(0, MAX_REPORT_CHARS)}
2003
+ ... (truncated)`;
2004
+ }
2005
+ const label = args.description ? ` "${args.description}"` : "";
2006
+ const stats = `[subagent${label}: ${toolCalls} tool call(s)]`;
2007
+ if (error) {
2008
+ return {
2009
+ content: [`Subagent failed: ${error}`, stats, report].filter(Boolean).join("\n"),
2010
+ isError: true
2011
+ };
2012
+ }
2013
+ return { content: `${stats}
2014
+ ${report || "(subagent produced no text output)"}` };
2015
+ }
2016
+ };
2017
+ }
2018
+
2019
+ // src/agent/loop.ts
2020
+ var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
2021
+ var AgentLoop = class {
2022
+ messages = [];
2023
+ opts;
2024
+ // Seq + user-message index of each turn started via stream(); lets /undo
2025
+ // match a retracted turn to the file snapshots it produced. Cleared when
2026
+ // history is replaced wholesale (resume/compact), because indices no longer
2027
+ // line up — in that case /undo only retracts messages, never wrong files.
2028
+ turnMarkers = [];
2029
+ titleScheduled = false;
2030
+ confirmHandler;
2031
+ // Hook failures never block the turn (except an explicit PreToolUse block);
2032
+ // their stderr surfaces through this callback (REPL system message / stderr
2033
+ // in print mode).
2034
+ onHookWarning;
2035
+ constructor(opts) {
2036
+ this.opts = opts;
2037
+ if (opts.system) {
2038
+ this.messages.push({ role: "system", content: opts.system });
2039
+ }
2040
+ if (opts.sessionStore) {
2041
+ this.bindSnapshotHooks(opts.sessionStore);
2042
+ }
2043
+ opts.registry?.register(createSkillTool({ cwd: opts.cwd }));
2044
+ const depth = opts.subagentDepth ?? 0;
2045
+ if (opts.registry && depth < MAX_SUBAGENT_DEPTH) {
2046
+ opts.registry.register(
2047
+ createSubagentTool({
2048
+ model: opts.model,
2049
+ config: opts.config,
2050
+ cwd: opts.cwd,
2051
+ system: opts.system,
2052
+ depth,
2053
+ getConfirmHandler: () => this.confirmHandler
2054
+ })
2055
+ );
2056
+ }
2057
+ }
2058
+ getMessages() {
2059
+ return this.messages;
2060
+ }
2061
+ // Swaps the persistence target (/new starts a fresh session mid-REPL).
2062
+ // Title generation is re-armed so the new session gets one after its first
2063
+ // turn, and snapshot checkpoints now flow to the new store.
2064
+ setSessionStore(store) {
2065
+ this.opts.sessionStore = store;
2066
+ this.titleScheduled = false;
2067
+ if (store) this.bindSnapshotHooks(store);
2068
+ }
2069
+ bindSnapshotHooks(store) {
2070
+ setSnapshotHooks({
2071
+ onPush: (snapshot) => store.appendCheckpoint(
2072
+ {
2073
+ id: snapshot.id,
2074
+ timestamp: snapshot.timestamp,
2075
+ path: snapshot.path,
2076
+ existed: snapshot.existed,
2077
+ toolName: snapshot.toolName,
2078
+ turn: snapshot.turn,
2079
+ messageIndex: snapshot.messageIndex
2080
+ },
2081
+ snapshot.content
2082
+ ),
2083
+ onRemove: (ids) => store.removeCheckpoints(ids)
2084
+ });
2085
+ }
2086
+ async loadMessages(messages) {
2087
+ this.messages = reconcileToolCalls(messages);
2088
+ this.turnMarkers = [];
2089
+ }
2090
+ // Drops the final user message and everything after it, and persists the
2091
+ // trimmed history so a later /resume does not bring the turn back.
2092
+ // `turn` is the retracted turn's snapshot seq when it could be verified
2093
+ // against the marker recorded at turn start — undefined means the caller
2094
+ // must not revert any file snapshots.
2095
+ async retractLastTurn() {
2096
+ const result = retractLastTurn([...this.messages]);
2097
+ if (result.removed === 0) return { removed: 0 };
2098
+ const userIndex = result.messages.length;
2099
+ this.messages = result.messages;
2100
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2101
+ const last = this.turnMarkers[this.turnMarkers.length - 1];
2102
+ let turn;
2103
+ if (last && last.userIndex === userIndex) {
2104
+ turn = last.seq;
2105
+ this.turnMarkers.pop();
2106
+ }
2107
+ return { removed: result.removed, turn };
2108
+ }
2109
+ // Cut point for a rewind: messages[index] should be the user message that
2110
+ // started the rewound turn, but compaction may have shifted indices, so
2111
+ // walk back to the nearest user message. Never touches a leading system
2112
+ // message.
2113
+ retractionCut(index) {
2114
+ const clamped = Math.max(0, Math.min(index, this.messages.length - 1));
2115
+ for (let i = clamped; i >= 0; i--) {
2116
+ if (this.messages[i]?.role === "user") return i;
2117
+ }
2118
+ return this.messages[0]?.role === "system" ? 1 : 0;
2119
+ }
2120
+ countRetraction(index) {
2121
+ return this.messages.length - this.retractionCut(index);
2122
+ }
2123
+ // Truncates the history back to the given message index (the user message
2124
+ // at that index and everything after it is dropped) and persists the
2125
+ // trimmed history, so /rewind stays consistent across resume.
2126
+ async retractFromIndex(index) {
2127
+ const cut = this.retractionCut(index);
2128
+ const removed = this.messages.length - cut;
2129
+ if (removed === 0) return 0;
2130
+ this.messages = this.messages.slice(0, cut);
2131
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2132
+ this.turnMarkers = this.turnMarkers.filter((m) => m.userIndex < cut);
2133
+ return removed;
2134
+ }
2135
+ async appendContextMessage(text, role = "user") {
2136
+ const message = { role, content: text };
2137
+ this.messages.push(message);
2138
+ await this.persist(message);
2139
+ }
2140
+ async persist(message) {
2141
+ await this.opts.sessionStore?.append(message);
2142
+ }
2143
+ // After the first assistant reply, kick off background title generation
2144
+ // for the session. Runs once per loop; the store-level title check makes
2145
+ // resumed sessions that already have a title a no-op.
2146
+ maybeScheduleTitle(input) {
2147
+ if (this.titleScheduled) return;
2148
+ const store = this.opts.sessionStore;
2149
+ if (!store) return;
2150
+ this.titleScheduled = true;
2151
+ scheduleSessionTitle(store, input, this.opts.model);
2152
+ }
2153
+ async *stream(input, signal, opts) {
2154
+ this.syncSystemMessage();
2155
+ const text = typeof input === "string" ? input : input.text;
2156
+ const images = typeof input === "string" ? [] : input.images;
2157
+ const userMessage = images.length > 0 ? {
2158
+ role: "user",
2159
+ content: [
2160
+ ...images.map((img) => ({
2161
+ type: "image",
2162
+ image: img.data,
2163
+ mimeType: img.mimeType
2164
+ })),
2165
+ { type: "text", text }
2166
+ ]
2167
+ } : { role: "user", content: text };
2168
+ this.messages.push(userMessage);
2169
+ const seq = (this.opts.subagentDepth ?? 0) === 0 ? beginTurn(this.messages.length - 1) : currentTurnSeq();
2170
+ this.turnMarkers.push({ seq, userIndex: this.messages.length - 1 });
2171
+ await this.persist(
2172
+ opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
2173
+ );
2174
+ const { config, registry, cwd } = this.opts;
2175
+ const aiTools = this.buildAiTools();
2176
+ for (let step = 0; step < config.maxSteps; step++) {
2177
+ const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2178
+ const compacted = compactMessages([...this.messages], maxTokens);
2179
+ if (compacted.compacted) {
2180
+ this.messages = await this.applyCompactionSummary(compacted);
2181
+ }
2182
+ let text2 = "";
2183
+ const toolCalls = [];
2184
+ let failed = false;
2185
+ try {
2186
+ for await (const event of this.streamOnce(aiTools, signal)) {
2187
+ if (event.type === "text-delta") {
2188
+ text2 += event.text;
2189
+ yield event;
2190
+ } else if (event.type === "reasoning") {
2191
+ yield event;
2192
+ } else if (event.type === "tool-call") {
2193
+ toolCalls.push({ id: event.id, name: event.name, args: event.args });
2194
+ yield event;
2195
+ } else if (event.type === "error") {
2196
+ failed = true;
2197
+ yield event;
2198
+ } else {
2199
+ yield event;
2200
+ }
2201
+ }
2202
+ } catch (error) {
2203
+ if (signal.aborted) return;
2204
+ yield { type: "error", error: error instanceof Error ? error : new Error(String(error)) };
2205
+ return;
2206
+ }
2207
+ if (failed) return;
2208
+ const assistantMessage = {
2209
+ role: "assistant",
2210
+ content: [
2211
+ ...text2 ? [{ type: "text", text: text2 }] : [],
2212
+ ...toolCalls.map((call) => ({
2213
+ type: "tool-call",
2214
+ toolCallId: call.id,
2215
+ toolName: call.name,
2216
+ args: call.args
2217
+ }))
2218
+ ]
2219
+ };
2220
+ this.messages.push(assistantMessage);
2221
+ await this.persist(assistantMessage);
2222
+ this.maybeScheduleTitle(text2);
2223
+ if (toolCalls.length === 0) {
2224
+ await this.runEventHooks("Stop");
2225
+ return;
2226
+ }
2227
+ const answered = /* @__PURE__ */ new Set();
2228
+ try {
2229
+ for (const call of toolCalls) {
2230
+ if (signal.aborted) break;
2231
+ const result = await this.executeTool(call, signal);
2232
+ const toolMessage = {
2233
+ role: "tool",
2234
+ content: [
2235
+ {
2236
+ type: "tool-result",
2237
+ toolCallId: call.id,
2238
+ toolName: call.name,
2239
+ result: result.content
2240
+ }
2241
+ ]
2242
+ };
2243
+ this.messages.push(toolMessage);
2244
+ await this.persist(toolMessage);
2245
+ answered.add(call.id);
2246
+ yield {
2247
+ type: "tool-result",
2248
+ id: call.id,
2249
+ name: call.name,
2250
+ content: result.content,
2251
+ isError: result.isError
2252
+ };
2253
+ }
2254
+ } finally {
2255
+ for (const call of toolCalls) {
2256
+ if (answered.has(call.id)) continue;
2257
+ const content = signal.aborted ? "Tool execution interrupted by user." : "Tool execution interrupted before a result was produced.";
2258
+ const synthetic = {
2259
+ role: "tool",
2260
+ content: [
2261
+ {
2262
+ type: "tool-result",
2263
+ toolCallId: call.id,
2264
+ toolName: call.name,
2265
+ result: content
2266
+ }
2267
+ ]
2268
+ };
2269
+ this.messages.push(synthetic);
2270
+ await this.persist(synthetic).catch(() => {
2271
+ });
2272
+ yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
2273
+ }
2274
+ }
2275
+ if (signal.aborted) return;
2276
+ }
2277
+ yield {
2278
+ type: "error",
2279
+ error: new Error(`Max steps (${config.maxSteps}) reached, stopping.`)
2280
+ };
2281
+ }
2282
+ // The permission mode can change at runtime, so the system message is
2283
+ // recomputed at the start of every turn instead of being frozen by the
2284
+ // constructor. Also restores a system message after loadMessages() (resume,
2285
+ // /model switch) replaced the history with one that has none. Git context
2286
+ // (branch, dirty count, recent commits) is refreshed here too, so the model
2287
+ // always sees the current repo state; any git failure is silently skipped.
2288
+ // Project memory (AGENTS.md in the cwd) is appended as a delimited block;
2289
+ // readProjectMemory caches by mtime, so this stays cheap per turn. The
2290
+ // skills listing is refreshed here as well, so skills added mid-session
2291
+ // show up in the next turn's system prompt.
2292
+ syncSystemMessage() {
2293
+ const base = this.opts.system;
2294
+ const plan = this.opts.config.permissionMode === "plan";
2295
+ const parts = [];
2296
+ if (base) parts.push(base);
2297
+ if (plan) parts.push(PLAN_MODE_PROMPT.trim());
2298
+ const memory = readProjectMemory(this.opts.cwd);
2299
+ if (memory) parts.push(memory);
2300
+ const skills = discoverSkills(this.opts.cwd);
2301
+ if (skills.length > 0) parts.push(formatSkillsBlock(skills));
2302
+ const git = getGitSummary(this.opts.cwd);
2303
+ if (git) parts.push(formatGitSummary(git));
2304
+ if (parts.length === 0) return;
2305
+ const content = parts.join("\n\n");
2306
+ const head = this.messages[0];
2307
+ if (head?.role === "system" && typeof head.content === "string") {
2308
+ if (head.content !== content) head.content = content;
2309
+ } else if (head?.role !== "system") {
2310
+ this.messages.unshift({ role: "system", content });
2311
+ }
2312
+ }
2313
+ async applyCompactionSummary(compacted) {
2314
+ const { config, model } = this.opts;
2315
+ if (config.contextCompaction !== "summary" || !model) {
2316
+ return compacted.messages;
2317
+ }
2318
+ const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
2319
+ const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
2320
+ try {
2321
+ const summary = await summarizeMessages(dropped, model);
2322
+ const messages = compacted.messages.slice();
2323
+ messages[headCount] = {
2324
+ role: "user",
2325
+ content: `[earlier conversation summarized]
2326
+ ${summary}`
2327
+ };
2328
+ return messages;
2329
+ } catch {
2330
+ return compacted.messages;
2331
+ }
2332
+ }
2333
+ async *streamOnce(aiTools, signal) {
2334
+ const { streamChat } = await import("./stream-Y55NRF2S.js");
2335
+ yield* streamChat({
2336
+ model: this.opts.model,
2337
+ messages: this.messages,
2338
+ tools: aiTools,
2339
+ abortSignal: signal,
2340
+ idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3
2341
+ });
2342
+ }
2343
+ buildAiTools() {
2344
+ const plan = this.opts.config.permissionMode === "plan";
2345
+ const tools = {};
2346
+ for (const t of this.opts.registry.list()) {
2347
+ if (plan && t.permission !== "read") continue;
2348
+ tools[t.name] = aiTool({
2349
+ description: t.description,
2350
+ parameters: t.parameters
2351
+ });
2352
+ }
2353
+ return tools;
2354
+ }
2355
+ async runEventHooks(event, toolName, toolInput) {
2356
+ const empty = { blocked: false, warnings: [] };
2357
+ if (this.opts.config.hooks.length === 0) return empty;
2358
+ try {
2359
+ const result = await runHooks(event, this.opts.config.hooks, {
2360
+ cwd: this.opts.cwd,
2361
+ sessionId: this.opts.sessionStore?.id,
2362
+ toolName,
2363
+ toolInput
2364
+ });
2365
+ for (const warning of result.warnings) this.onHookWarning?.(warning);
2366
+ return result;
2367
+ } catch {
2368
+ return empty;
2369
+ }
2370
+ }
2371
+ async executeTool(call, signal) {
2372
+ const { registry, config, cwd } = this.opts;
2373
+ const tool = registry.get(call.name);
2374
+ if (!tool) {
2375
+ return { content: `Unknown tool: ${call.name}`, isError: true };
2376
+ }
2377
+ const decision = checkPermission(
2378
+ config.permissionMode,
2379
+ { toolName: call.name, args: call.args, level: tool.permission },
2380
+ { cwd },
2381
+ config.permissions.allow
2382
+ );
2383
+ if (decision === "deny") {
2384
+ return { content: `Permission denied for tool "${call.name}".`, isError: true };
2385
+ }
2386
+ if (decision === "ask") {
2387
+ let approved = false;
2388
+ try {
2389
+ approved = this.confirmHandler ? await this.confirmHandler({
2390
+ toolName: call.name,
2391
+ args: call.args,
2392
+ level: tool.permission
2393
+ }) : false;
2394
+ } catch {
2395
+ approved = false;
2396
+ }
2397
+ if (!approved) {
2398
+ return { content: `User rejected tool "${call.name}".`, isError: true };
2399
+ }
2400
+ }
2401
+ const parsed = tool.parameters.safeParse(call.args);
2402
+ if (!parsed.success) {
2403
+ return { content: `Invalid arguments: ${parsed.error.message}`, isError: true };
2404
+ }
2405
+ const pre = await this.runEventHooks("PreToolUse", call.name, call.args);
2406
+ if (pre.blocked) {
2407
+ return {
2408
+ content: `Tool "${call.name}" blocked by a PreToolUse hook: ${pre.reason}`,
2409
+ isError: true
2410
+ };
2411
+ }
2412
+ let result;
2413
+ try {
2414
+ result = await tool.execute(parsed.data, { cwd, abortSignal: signal });
2415
+ } catch (error) {
2416
+ if (signal.aborted) {
2417
+ return { content: "Tool execution aborted.", isError: true };
2418
+ }
2419
+ return {
2420
+ content: `Tool error: ${error instanceof Error ? error.message : String(error)}`,
2421
+ isError: true
2422
+ };
2423
+ }
2424
+ if (!result.isError) {
2425
+ await this.runEventHooks("PostToolUse", call.name, call.args);
2426
+ }
2427
+ return result;
2428
+ }
2429
+ };
2430
+
2431
+ export {
2432
+ estimateTokens,
2433
+ summarizeMessages,
2434
+ compactMessages,
2435
+ WORKING_DIFF_MAX_LINES,
2436
+ isGitRepo,
2437
+ getGitSummary,
2438
+ collectCommitContext,
2439
+ buildCommitPrompt,
2440
+ collectWorkingDiff,
2441
+ reconcileToolCalls,
2442
+ isAllowedByRules,
2443
+ buildAllowRule,
2444
+ isDangerousCommand,
2445
+ listSnapshots,
2446
+ clearSnapshots,
2447
+ hydrateSnapshots,
2448
+ undoTurnSnapshots,
2449
+ rewindToSnapshot,
2450
+ starHome,
2451
+ globalConfigPath,
2452
+ sessionsDir,
2453
+ projectConfigPath,
2454
+ discoverSkills,
2455
+ defaultTaskManager,
2456
+ resolveShell,
2457
+ bashTool,
2458
+ SKIP_DIRS,
2459
+ isSensitivePath2 as isSensitivePath,
2460
+ formatTaskList,
2461
+ formatTaskStarted,
2462
+ formatTaskFinished,
2463
+ TodoStore,
2464
+ formatTodos,
2465
+ createDefaultRegistry,
2466
+ PLAN_MODE_PROMPT,
2467
+ AgentLoop
2468
+ };
2469
+ //# sourceMappingURL=chunk-MJ2O7NWI.js.map