@cryer/star-cli 0.3.1 → 0.3.3

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.
@@ -1,4 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ computeRetryDelayMs,
4
+ gitTreesDir,
5
+ isRetryableStreamError,
6
+ responseBodyOf,
7
+ starHome,
8
+ summarizeStreamError,
9
+ userMemoryPath
10
+ } from "./chunk-B6P4VUBE.js";
2
11
 
3
12
  // src/agent/loop.ts
4
13
  import { tool as aiTool, generateText as generateText3 } from "ai";
@@ -82,14 +91,18 @@ function serializeMessage(message) {
82
91
  });
83
92
  return `${message.role}: ${parts.join("\n")}`;
84
93
  }
85
- async function summarizeMessages(messages, model) {
94
+ async function summarizeMessages(messages, model, signal) {
86
95
  const transcript = messages.map(serializeMessage).join("\n");
87
96
  const { text } = await generateText({
88
97
  model,
89
98
  system: SUMMARY_SYSTEM_PROMPT,
90
99
  prompt: `Summarize this conversation so far:
91
100
 
92
- ${transcript}`
101
+ ${transcript}`,
102
+ // A hung relay must not stall the whole turn on the summary: cap it at
103
+ // 60s and let the caller's abort (Esc) cut it short as well; the caller
104
+ // falls back to the truncation placeholder on any failure.
105
+ abortSignal: signal ? AbortSignal.any([signal, AbortSignal.timeout(6e4)]) : AbortSignal.timeout(6e4)
93
106
  });
94
107
  return text.trim();
95
108
  }
@@ -165,6 +178,16 @@ function getGitSummary(cwd) {
165
178
  const log = runGit(["log", "--oneline", "-3"], cwd) ?? "";
166
179
  return { branch, dirtyCount, recentCommits: log === "" ? [] : log.split("\n") };
167
180
  }
181
+ var GIT_SUMMARY_CACHE_TTL_MS = 15e3;
182
+ var gitSummaryCache = /* @__PURE__ */ new Map();
183
+ function getGitSummaryCached(cwd) {
184
+ const now = Date.now();
185
+ const hit = gitSummaryCache.get(cwd);
186
+ if (hit && now - hit.at < GIT_SUMMARY_CACHE_TTL_MS) return hit.summary;
187
+ const summary = getGitSummary(cwd);
188
+ gitSummaryCache.set(cwd, { at: now, summary });
189
+ return summary;
190
+ }
168
191
  function formatGitSummary(summary) {
169
192
  const lines = [
170
193
  "Git context for the working directory (refreshed each turn):",
@@ -325,7 +348,8 @@ var IMAGE_SIZE_KEYWORDS = /too[\s-]*(large|big)|exceeds?|dimensions?|resolution|
325
348
  function isOversizedImageError(error) {
326
349
  const status = error.statusCode;
327
350
  if (typeof status !== "number" || status < 400 || status >= 500) return false;
328
- return /image/i.test(error.message) && IMAGE_SIZE_KEYWORDS.test(error.message);
351
+ const text = [error.message, responseBodyOf(error)].filter((part) => typeof part === "string").join("\n");
352
+ return /image/i.test(text) && IMAGE_SIZE_KEYWORDS.test(text);
329
353
  }
330
354
  function imagePartBytes(image) {
331
355
  if (typeof image === "string") {
@@ -534,8 +558,50 @@ async function runHooks(event, hooks2, ctx) {
534
558
  }
535
559
 
536
560
  // src/permissions/gate.ts
561
+ import fs from "fs";
537
562
  import path from "path";
538
563
 
564
+ // src/core/sensitive.ts
565
+ var SENSITIVE_BASENAMES = /* @__PURE__ */ new Set([
566
+ ".env",
567
+ "id_rsa",
568
+ "id_dsa",
569
+ "id_ecdsa",
570
+ "id_ed25519",
571
+ ".npmrc",
572
+ ".netrc",
573
+ ".pgpass"
574
+ ]);
575
+ var SENSITIVE_SUFFIXES = [".pem", ".key", ".p12", ".pfx"];
576
+ var SENSITIVE_ENV_EXEMPTIONS = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
577
+ function pathSegments(p) {
578
+ return p.replace(/\\/g, "/").split("/").filter(Boolean).map((s) => s.toLowerCase());
579
+ }
580
+ function isSensitivePath(p) {
581
+ const segments = pathSegments(p);
582
+ const base = segments[segments.length - 1] ?? "";
583
+ if (SENSITIVE_ENV_EXEMPTIONS.has(base)) {
584
+ return false;
585
+ }
586
+ if (base === ".env" || base.startsWith(".env.")) {
587
+ return true;
588
+ }
589
+ if (SENSITIVE_BASENAMES.has(base)) {
590
+ return true;
591
+ }
592
+ if (SENSITIVE_SUFFIXES.some((suffix) => base.endsWith(suffix))) {
593
+ return true;
594
+ }
595
+ const dir = segments[segments.length - 2] ?? "";
596
+ if (dir === ".aws" && base === "credentials") {
597
+ return true;
598
+ }
599
+ if (dir === ".kube" && base === "config") {
600
+ return true;
601
+ }
602
+ return false;
603
+ }
604
+
539
605
  // src/permissions/allow.ts
540
606
  function parseAllowRule(rule) {
541
607
  const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
@@ -559,11 +625,32 @@ function matchAllowRule(rule, req) {
559
625
  const target = requestTarget(req);
560
626
  return target !== void 0 && globMatch(rule.pattern, target);
561
627
  }
628
+ function splitCommandChain(command) {
629
+ if (command.includes("$(") || command.includes("`")) return null;
630
+ return command.split(/&&|\|\||[;|\n]/).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
631
+ }
632
+ function withCommand(req, command) {
633
+ return {
634
+ ...req,
635
+ args: { ...req.args ?? {}, command }
636
+ };
637
+ }
562
638
  function isAllowedByRules(rules, req) {
563
- return matchesAnyRule(rules, req);
639
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
640
+ const command = requestTarget(req);
641
+ if (command === void 0) return matchesAnyRule(rules, req);
642
+ const segments = splitCommandChain(command);
643
+ if (segments === null) return false;
644
+ if (segments.length === 0) return matchesAnyRule(rules, req);
645
+ return segments.every((segment) => matchesAnyRule(rules, withCommand(req, segment)));
564
646
  }
565
647
  function isDeniedByRules(rules, req) {
566
- return matchesAnyRule(rules, req);
648
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
649
+ const command = requestTarget(req);
650
+ if (command === void 0) return matchesAnyRule(rules, req);
651
+ const segments = splitCommandChain(command);
652
+ if (segments === null || segments.length === 0) return matchesAnyRule(rules, req);
653
+ return segments.some((segment) => matchesAnyRule(rules, withCommand(req, segment)));
567
654
  }
568
655
  function matchesAnyRule(rules, req) {
569
656
  return rules.some((raw) => {
@@ -577,10 +664,17 @@ function buildAllowRule(req) {
577
664
  }
578
665
 
579
666
  // src/permissions/gate.ts
580
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
667
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "grep", "glob"]);
581
668
  var DANGEROUS_COMMAND_PATTERNS = [
582
669
  /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
583
670
  /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
671
+ /rm\s+-rf\s+\.\/?(?:\s|$|;|&)/i,
672
+ /rm\s+-rf\s+\*(?:\s|$|;|&)/i,
673
+ /\bsudo\s+rm\b/i,
674
+ /\bdel\s+\/s\b/i,
675
+ /\brd\s+\/s\b/i,
676
+ /\bformat\s+[a-z]:/i,
677
+ /\bdiskpart\b/i,
584
678
  /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
585
679
  /\bmkfs\b/i,
586
680
  /dd\s+[^|;]*\bif=/i,
@@ -610,12 +704,30 @@ function isOutsideCwd(p, cwd) {
610
704
  const base = normalizeAbsolute(cwd, cwd);
611
705
  return abs !== base && !abs.startsWith(`${base}/`);
612
706
  }
613
- function isSensitivePath(p) {
614
- const base = p.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
615
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
616
- return false;
707
+ function resolveRealPath(p, cwd) {
708
+ let current = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
709
+ const missing = [];
710
+ for (; ; ) {
711
+ try {
712
+ return path.join(fs.realpathSync(current), ...missing.reverse());
713
+ } catch {
714
+ const parent = path.dirname(current);
715
+ if (parent === current) return null;
716
+ missing.push(path.basename(current));
717
+ current = parent;
718
+ }
719
+ }
720
+ }
721
+ function isOutsideForWrite(p, cwd) {
722
+ const resolved = resolveRealPath(p, cwd);
723
+ if (resolved === null) return isOutsideCwd(p, cwd);
724
+ let realCwd;
725
+ try {
726
+ realCwd = fs.realpathSync(cwd);
727
+ } catch {
728
+ return isOutsideCwd(p, cwd);
617
729
  }
618
- return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
730
+ return isOutsideCwd(resolved, realCwd);
619
731
  }
620
732
  function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
621
733
  if (mode === "yolo") {
@@ -627,10 +739,12 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
627
739
  return "deny";
628
740
  }
629
741
  if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
630
- if ((req.toolName === "write_file" || req.toolName === "edit_file") && isSensitivePath(filePath)) {
742
+ const isWriteTool = req.toolName === "write_file" || req.toolName === "edit_file";
743
+ if (isWriteTool && isSensitivePath(filePath)) {
631
744
  return "deny";
632
745
  }
633
- if (isOutsideCwd(filePath, ctx.cwd)) {
746
+ const outside = isWriteTool ? isOutsideForWrite(filePath, ctx.cwd) : isOutsideCwd(filePath, ctx.cwd);
747
+ if (outside) {
634
748
  if (mode === "ask" && req.level === "read") {
635
749
  return "ask";
636
750
  }
@@ -701,10 +815,93 @@ function scheduleSessionTitle(store, userText, model) {
701
815
  });
702
816
  }
703
817
 
704
- // src/tools/fs/snapshots.ts
705
- import { mkdir, readFile, rm, writeFile } from "fs/promises";
818
+ // src/snapshot/git-tree.ts
819
+ import { execFile } from "child_process";
820
+ import { existsSync } from "fs";
821
+ import { mkdir } from "fs/promises";
822
+ import os from "os";
706
823
  import path2 from "path";
824
+ var GIT_TIMEOUT_MS2 = 1e4;
825
+ var GIT_MAX_BUFFER2 = 8 * 1024 * 1024;
826
+ function runGit2(args) {
827
+ return new Promise((resolve) => {
828
+ execFile(
829
+ "git",
830
+ args,
831
+ { timeout: GIT_TIMEOUT_MS2, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
832
+ (error, stdout) => {
833
+ resolve(error ? null : stdout.trim());
834
+ }
835
+ );
836
+ });
837
+ }
838
+ var gitAvailable = null;
839
+ async function isGitAvailable() {
840
+ if (gitAvailable === null) {
841
+ gitAvailable = await runGit2(["--version"]) !== null;
842
+ }
843
+ return gitAvailable;
844
+ }
845
+ function treeRepoDir(cwd) {
846
+ const key = Buffer.from(path2.resolve(cwd), "utf8").toString("base64url");
847
+ return path2.join(gitTreesDir(), key);
848
+ }
849
+ function isGuardedDir(cwd) {
850
+ const resolved = path2.resolve(cwd);
851
+ const home = path2.resolve(os.homedir());
852
+ const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
853
+ return same(resolved, home) || same(resolved, path2.parse(resolved).root);
854
+ }
855
+ async function ensureTreeRepo(cwd) {
856
+ const dir = treeRepoDir(cwd);
857
+ if (existsSync(path2.join(dir, "HEAD"))) return dir;
858
+ await mkdir(path2.dirname(dir), { recursive: true });
859
+ const out = await runGit2(["init", "--bare", dir]);
860
+ return out === null ? null : dir;
861
+ }
862
+ function repoArgs(dir, cwd) {
863
+ return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
864
+ }
865
+ async function trackTree(cwd) {
866
+ if (isGuardedDir(cwd)) return null;
867
+ if (!await isGitAvailable()) return null;
868
+ const dir = await ensureTreeRepo(cwd);
869
+ if (!dir) return null;
870
+ const base = repoArgs(dir, cwd);
871
+ if (await runGit2([...base, "add", "-A"]) === null) return null;
872
+ return runGit2([...base, "write-tree"]);
873
+ }
874
+ async function restoreTree(cwd, hash) {
875
+ if (isGuardedDir(cwd)) return false;
876
+ if (!await isGitAvailable()) return false;
877
+ const dir = treeRepoDir(cwd);
878
+ if (!existsSync(path2.join(dir, "HEAD"))) return false;
879
+ const base = repoArgs(dir, cwd);
880
+ if (await runGit2([...base, "read-tree", hash]) === null) return false;
881
+ if (await runGit2([...base, "checkout-index", "-f", "-a"]) === null) return false;
882
+ if (await runGit2([...base, "clean", "-fd"]) === null) return false;
883
+ return true;
884
+ }
885
+ async function diffTreeNames(cwd, hash) {
886
+ if (!await isGitAvailable()) return null;
887
+ const dir = treeRepoDir(cwd);
888
+ if (!existsSync(path2.join(dir, "HEAD"))) return null;
889
+ const base = repoArgs(dir, cwd);
890
+ const changed = await runGit2([...base, "diff", "--name-only", hash]);
891
+ const untracked = await runGit2([...base, "ls-files", "--others", "--exclude-standard"]);
892
+ if (changed === null || untracked === null) return null;
893
+ const names = /* @__PURE__ */ new Set();
894
+ for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
895
+ if (line !== "") names.add(line);
896
+ }
897
+ return [...names];
898
+ }
899
+
900
+ // src/tools/fs/snapshots.ts
901
+ import { mkdir as mkdir2, readFile, rm, stat, writeFile } from "fs/promises";
902
+ import path3 from "path";
707
903
  var MAX_SNAPSHOTS = 50;
904
+ var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
708
905
  var stack = [];
709
906
  var activeTurn = 0;
710
907
  var activeTurnMessageIndex = -1;
@@ -726,28 +923,48 @@ function currentTurnSeq() {
726
923
  function currentTurnMessageIndex() {
727
924
  return activeTurnMessageIndex;
728
925
  }
729
- async function captureSnapshot(filePath, toolName) {
926
+ function ownerOf(snapshot) {
927
+ return snapshot.owner ?? "root";
928
+ }
929
+ async function captureSnapshot(filePath, toolName, snapshotContext) {
730
930
  let content = null;
731
931
  let existed = false;
732
- try {
733
- content = await readFile(filePath, "utf8");
932
+ let contentTooLarge = false;
933
+ const st = await stat(filePath).catch(() => null);
934
+ if (st?.isFile()) {
734
935
  existed = true;
735
- } catch {
936
+ if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
937
+ contentTooLarge = true;
938
+ } else {
939
+ try {
940
+ content = await readFile(filePath, "utf8");
941
+ } catch {
942
+ existed = false;
943
+ }
944
+ }
736
945
  }
737
946
  return {
738
947
  id: nextSnapshotId(),
739
948
  path: filePath,
740
949
  existed,
741
950
  content,
951
+ contentTooLarge: contentTooLarge || void 0,
742
952
  toolName,
743
953
  timestamp: Date.now(),
744
- turn: activeTurn,
745
- messageIndex: activeTurnMessageIndex
954
+ turn: snapshotContext?.turn ?? activeTurn,
955
+ messageIndex: snapshotContext?.messageIndex ?? activeTurnMessageIndex,
956
+ owner: snapshotContext?.owner ?? "root"
746
957
  };
747
958
  }
748
959
  async function pushSnapshot(snapshot) {
749
960
  stack.push(snapshot);
750
- await hooks?.onPush?.(snapshot);
961
+ try {
962
+ await hooks?.onPush?.(snapshot);
963
+ } catch (err) {
964
+ if (process.env.STAR_DEBUG) {
965
+ console.error("[star] failed to persist snapshot checkpoint:", err);
966
+ }
967
+ }
751
968
  if (stack.length > MAX_SNAPSHOTS) {
752
969
  const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
753
970
  await removeFromStore(evicted.map((s) => s.id));
@@ -780,191 +997,156 @@ async function resolveContent(snapshot) {
780
997
  }
781
998
  async function revert(snapshot) {
782
999
  if (snapshot.existed) {
1000
+ if (snapshot.contentTooLarge) {
1001
+ return `Skipped ${snapshot.path}: content too large, not restored.`;
1002
+ }
783
1003
  const content = await resolveContent(snapshot);
784
1004
  if (content === null) {
785
1005
  return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
786
1006
  }
787
- await mkdir(path2.dirname(snapshot.path), { recursive: true });
1007
+ await mkdir2(path3.dirname(snapshot.path), { recursive: true });
788
1008
  await writeFile(snapshot.path, content, "utf8");
789
1009
  return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
790
1010
  }
791
1011
  await rm(snapshot.path, { force: true });
792
1012
  return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
793
1013
  }
794
- function listTurnSnapshots(turn) {
795
- return stack.filter((snapshot) => snapshot.turn === turn).reverse();
1014
+ function listTurnSnapshots(turn, includeSubagent = false) {
1015
+ return stack.filter(
1016
+ (snapshot) => snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")
1017
+ ).reverse();
796
1018
  }
797
1019
  function resolveSnapshotContent(snapshot) {
798
1020
  return resolveContent(snapshot);
799
1021
  }
800
- async function undoTurnSnapshots(turn) {
1022
+ async function undoTurnSnapshots(turn, includeSubagent = false) {
801
1023
  const matches = [];
802
1024
  for (let i = stack.length - 1; i >= 0; i--) {
803
1025
  const snapshot = stack[i];
804
- if (snapshot && snapshot.turn === turn) {
1026
+ if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
805
1027
  matches.push(snapshot);
806
- stack.splice(i, 1);
807
1028
  }
808
1029
  }
809
- await removeFromStore(matches.map((s) => s.id));
810
1030
  const messages = [];
1031
+ const revertedIds = [];
811
1032
  for (const snapshot of matches) {
812
- messages.push(await revert(snapshot));
1033
+ try {
1034
+ messages.push(await revert(snapshot));
1035
+ const idx = stack.indexOf(snapshot);
1036
+ if (idx >= 0) stack.splice(idx, 1);
1037
+ revertedIds.push(snapshot.id);
1038
+ } catch (err) {
1039
+ messages.push(
1040
+ `Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
1041
+ );
1042
+ }
813
1043
  }
1044
+ await removeFromStore(revertedIds);
814
1045
  return messages;
815
1046
  }
816
- async function rewindToSnapshot(id) {
1047
+ async function dropTurnSnapshots(turn, includeSubagent = false) {
1048
+ const dropped = [];
1049
+ for (let i = stack.length - 1; i >= 0; i--) {
1050
+ const snapshot = stack[i];
1051
+ if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
1052
+ dropped.push(snapshot.id);
1053
+ stack.splice(i, 1);
1054
+ }
1055
+ }
1056
+ await removeFromStore(dropped);
1057
+ return dropped.length;
1058
+ }
1059
+ async function rewindToSnapshot(id, includeSubagent = false) {
817
1060
  if (!stack.some((snapshot) => snapshot.id === id)) return null;
818
1061
  const matches = [];
819
1062
  for (let i = stack.length - 1; i >= 0; i--) {
820
1063
  const snapshot = stack[i];
821
- if (snapshot && snapshot.id >= id) {
1064
+ if (snapshot && snapshot.id >= id && (includeSubagent || ownerOf(snapshot) === "root")) {
822
1065
  matches.push(snapshot);
823
- stack.splice(i, 1);
824
1066
  }
825
1067
  }
826
- await removeFromStore(matches.map((s) => s.id));
827
1068
  const reverted = [];
1069
+ const revertedIds = [];
828
1070
  let messageIndex = null;
829
1071
  for (const snapshot of matches) {
830
- reverted.push(await revert(snapshot));
831
- if (snapshot.messageIndex >= 0) {
832
- messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
1072
+ try {
1073
+ reverted.push(await revert(snapshot));
1074
+ const idx = stack.indexOf(snapshot);
1075
+ if (idx >= 0) stack.splice(idx, 1);
1076
+ revertedIds.push(snapshot.id);
1077
+ if (snapshot.messageIndex >= 0) {
1078
+ messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
1079
+ }
1080
+ } catch (err) {
1081
+ reverted.push(
1082
+ `Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
1083
+ );
833
1084
  }
834
1085
  }
1086
+ await removeFromStore(revertedIds);
835
1087
  return { reverted, messageIndex };
836
1088
  }
837
1089
 
838
- // src/tools/todo.ts
839
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
840
- import path3 from "path";
1090
+ // src/tools/bash.ts
1091
+ import { spawn as spawn3 } from "child_process";
1092
+ import { existsSync as existsSync2 } from "fs";
1093
+ import path4 from "path";
841
1094
  import { z } from "zod";
842
- var todoItemSchema = z.object({
843
- id: z.number().int(),
844
- title: z.string(),
845
- status: z.enum(["pending", "in_progress", "done"])
846
- });
847
- var writeSchema = z.object({
848
- todos: z.array(todoItemSchema).describe("The full todo list, replacing the current one")
849
- });
850
- var readSchema = z.object({});
851
- function fileFor(cwd) {
852
- return path3.join(cwd, ".star", "todos.json");
853
- }
854
- var TodoStore = class {
855
- items = /* @__PURE__ */ new Map();
856
- list() {
857
- return [...this.items.values()];
858
- }
859
- replace(items) {
860
- this.items.clear();
861
- for (const item of items) {
862
- this.items.set(item.id, item);
863
- }
864
- }
865
- async load(cwd) {
866
- let raw;
867
- try {
868
- raw = await readFile2(fileFor(cwd), "utf8");
869
- } catch {
870
- return;
871
- }
872
- try {
873
- const parsed = JSON.parse(raw);
874
- if (Array.isArray(parsed)) {
875
- this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
876
- }
877
- } catch {
878
- }
879
- }
880
- async save(cwd) {
881
- await mkdir2(path3.join(cwd, ".star"), { recursive: true });
882
- await writeFile2(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
883
- `);
884
- }
885
- };
886
- var SYMBOLS = {
887
- pending: "[ ]",
888
- in_progress: "[~]",
889
- done: "[x]"
890
- };
891
- function formatTodos(items) {
892
- if (items.length === 0) {
893
- return "No todos.";
894
- }
895
- const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
896
- const width = Math.max(...lines.map((l) => l.length));
897
- return lines.map((line, i) => {
898
- const status = items[i]?.status;
899
- return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
900
- }).join("\n");
901
- }
902
- var defaultStore = new TodoStore();
903
- function parseTodoArgs(args) {
904
- const parsed = writeSchema.safeParse(args);
905
- return parsed.success ? parsed.data.todos : null;
906
- }
907
- function pendingTodoTitles(args) {
908
- return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
909
- }
910
- async function loadTodos(cwd) {
911
- await defaultStore.load(cwd);
912
- return defaultStore.list();
913
- }
914
- function resetTodos() {
915
- defaultStore.replace([]);
916
- }
917
- function createTodoTools(store = defaultStore) {
918
- const todoWrite = {
919
- name: "todo_write",
920
- description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
921
- permission: "read",
922
- parameters: writeSchema,
923
- async execute(args, ctx) {
924
- const parsed = writeSchema.safeParse(args);
925
- if (!parsed.success) {
926
- return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
927
- }
928
- store.replace(parsed.data.todos);
929
- await store.save(ctx.cwd);
930
- const items = store.list();
931
- const done = items.filter((it) => it.status === "done").length;
932
- return { content: `Todo list updated (${done}/${items.length} done).` };
933
- }
934
- };
935
- const todoRead = {
936
- name: "todo_read",
937
- description: "Read the current todo list.",
938
- permission: "read",
939
- parameters: readSchema,
940
- async execute() {
941
- return { content: formatTodos(store.list()) };
942
- }
943
- };
944
- return [todoWrite, todoRead];
945
- }
946
1095
 
947
- // src/agent/agent-tasks.ts
1096
+ // src/tasks/manager.ts
1097
+ import { spawn as spawn2 } from "child_process";
948
1098
  import { EventEmitter } from "events";
949
- var AgentTaskManager = class extends EventEmitter {
1099
+ var MAX_TASK_OUTPUT = 1e5;
1100
+ var DEFAULT_TASK_TIMEOUT = 3600;
1101
+ var MAX_FINISHED_RECORDS = 50;
1102
+ var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
1103
+ var TaskManager = class extends EventEmitter {
950
1104
  records = /* @__PURE__ */ new Map();
951
- counter = 0;
952
- start(run, meta) {
953
- const id = `agent-${++this.counter}`;
1105
+ seq = 0;
1106
+ start(opts) {
1107
+ const id = `task-${++this.seq}`;
1108
+ const spec = resolveShell();
1109
+ const child = spawn2(spec.shell, spec.wrap(opts.command), {
1110
+ cwd: opts.cwd,
1111
+ windowsHide: true,
1112
+ // POSIX: the child leads its own process group so killTree can SIGKILL
1113
+ // the whole tree with a negative pid (Windows uses taskkill /t).
1114
+ detached: process.platform !== "win32"
1115
+ });
954
1116
  const rec = {
955
1117
  id,
956
- prompt: meta.prompt,
957
- description: meta.description,
1118
+ command: opts.command,
1119
+ description: opts.description,
958
1120
  status: "running",
959
1121
  startedAt: Date.now(),
960
- result: "",
961
- controller: new AbortController(),
962
- notified: false
1122
+ output: "",
1123
+ truncated: false,
1124
+ child
963
1125
  };
964
1126
  this.records.set(id, rec);
965
- void run(rec.controller.signal).then((result) => this.finish(rec, "completed", result)).catch(
966
- (error) => this.finish(rec, "failed", error instanceof Error ? error.message : String(error))
967
- );
1127
+ const append = (d) => {
1128
+ rec.output += d.toString("utf8");
1129
+ if (rec.output.length > MAX_TASK_OUTPUT) {
1130
+ rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
1131
+ rec.truncated = true;
1132
+ }
1133
+ };
1134
+ child.stdout.on("data", append);
1135
+ child.stderr.on("data", append);
1136
+ child.on("error", (err) => {
1137
+ append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
1138
+ `));
1139
+ if (rec.status === "running") {
1140
+ this.finish(rec, "failed", null);
1141
+ }
1142
+ });
1143
+ child.on("close", (code) => {
1144
+ if (rec.status !== "running") return;
1145
+ this.finish(rec, code === 0 ? "completed" : "failed", code);
1146
+ });
1147
+ const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
1148
+ rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
1149
+ rec.timer.unref();
968
1150
  this.emitUpdate(rec);
969
1151
  return this.snapshot(rec);
970
1152
  }
@@ -985,343 +1167,46 @@ var AgentTaskManager = class extends EventEmitter {
985
1167
  kill(id) {
986
1168
  const rec = this.records.get(id);
987
1169
  if (!rec || rec.status !== "running") return void 0;
988
- rec.controller.abort();
989
- this.finish(rec, "stopped", rec.result || "Stopped by user.");
1170
+ this.terminate(rec, "stopped");
990
1171
  return this.snapshot(rec);
991
1172
  }
992
1173
  cleanup() {
993
1174
  const killed = [];
994
1175
  for (const rec of this.records.values()) {
995
1176
  if (rec.status === "running") {
996
- rec.controller.abort();
997
- this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
1177
+ this.terminate(rec, "stopped");
998
1178
  killed.push(this.snapshot(rec));
999
1179
  }
1000
1180
  }
1001
1181
  return killed;
1002
1182
  }
1003
- // Finished tasks whose result has not yet been handed to the parent loop.
1004
- drainNotifications() {
1005
- const drained = [];
1006
- for (const rec of this.records.values()) {
1007
- if (rec.status !== "running" && !rec.notified) {
1008
- rec.notified = true;
1009
- drained.push(this.snapshot(rec));
1010
- }
1011
- }
1012
- return drained;
1183
+ terminate(rec, status) {
1184
+ killTree2(rec.child);
1185
+ this.finish(rec, status, null);
1013
1186
  }
1014
- finish(rec, status, result) {
1015
- if (rec.status !== "running") return;
1187
+ finish(rec, status, exitCode) {
1188
+ if (rec.timer) clearTimeout(rec.timer);
1016
1189
  rec.status = status;
1017
- rec.result = result;
1190
+ rec.exitCode = exitCode;
1018
1191
  rec.endedAt = Date.now();
1192
+ this.pruneFinished();
1019
1193
  this.emitUpdate(rec);
1020
1194
  }
1195
+ pruneFinished() {
1196
+ const finished = [...this.records.values()].filter((rec) => rec.status !== "running");
1197
+ if (finished.length <= MAX_FINISHED_RECORDS) return;
1198
+ finished.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
1199
+ for (const rec of finished.slice(0, finished.length - MAX_FINISHED_RECORDS)) {
1200
+ this.records.delete(rec.id);
1201
+ }
1202
+ }
1021
1203
  emitUpdate(rec) {
1022
1204
  this.emit("update", this.snapshot(rec));
1023
1205
  }
1024
1206
  snapshot(rec) {
1025
1207
  return {
1026
1208
  id: rec.id,
1027
- prompt: rec.prompt,
1028
- description: rec.description,
1029
- status: rec.status,
1030
- startedAt: rec.startedAt,
1031
- endedAt: rec.endedAt,
1032
- result: rec.result
1033
- };
1034
- }
1035
- };
1036
- var defaultAgentTasks = new AgentTaskManager();
1037
-
1038
- // src/agent/project-memory.ts
1039
- import { readFileSync, statSync } from "fs";
1040
- import path4 from "path";
1041
- var PROJECT_MEMORY_FILE = "AGENTS.md";
1042
- var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
1043
- var cache = /* @__PURE__ */ new Map();
1044
- function readProjectMemory(cwd) {
1045
- const file = path4.join(cwd, PROJECT_MEMORY_FILE);
1046
- let mtimeMs;
1047
- try {
1048
- mtimeMs = statSync(file).mtimeMs;
1049
- } catch {
1050
- return null;
1051
- }
1052
- const hit = cache.get(file);
1053
- if (hit && hit.mtimeMs === mtimeMs) return hit.block;
1054
- let block = null;
1055
- try {
1056
- let body = readFileSync(file, "utf8").trim();
1057
- if (body) {
1058
- if (body.length > PROJECT_MEMORY_MAX_CHARS) {
1059
- body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
1060
-
1061
- [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
1062
- }
1063
- block = `# Project instructions (AGENTS.md)
1064
- ${body}`;
1065
- }
1066
- } catch {
1067
- block = null;
1068
- }
1069
- cache.set(file, { mtimeMs, block });
1070
- return block;
1071
- }
1072
-
1073
- // src/agent/skills.ts
1074
- import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
1075
- import path6 from "path";
1076
- import { z as z2 } from "zod";
1077
-
1078
- // src/config/paths.ts
1079
- import os from "os";
1080
- import path5 from "path";
1081
- function starHome() {
1082
- return process.env.STAR_HOME ?? path5.join(os.homedir(), ".star-cli");
1083
- }
1084
- function globalConfigPath() {
1085
- return path5.join(starHome(), "config.toml");
1086
- }
1087
- function sessionsDir() {
1088
- return path5.join(starHome(), "sessions");
1089
- }
1090
- function userMemoryPath() {
1091
- return path5.join(starHome(), "MEMORY.md");
1092
- }
1093
- function envFilePath() {
1094
- return path5.join(starHome(), ".env");
1095
- }
1096
- function projectConfigPath(cwd) {
1097
- return path5.join(cwd, ".star", "config.toml");
1098
- }
1099
-
1100
- // src/agent/skills.ts
1101
- var SKILL_FILE = "SKILL.md";
1102
- var SKILL_MAX_CHARS = 32 * 1024;
1103
- var NAME_PATTERN = /^[a-z0-9-]+$/;
1104
- function parseFrontmatter(raw) {
1105
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
1106
- if (!match) return null;
1107
- const meta = {};
1108
- for (const line of (match[1] ?? "").split(/\r?\n/)) {
1109
- const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
1110
- if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
1111
- }
1112
- return { meta, body: raw.slice(match[0].length).trim() };
1113
- }
1114
- var cache2 = /* @__PURE__ */ new Map();
1115
- function parseSkillFile(filePath, dirName, scope) {
1116
- let mtimeMs;
1117
- try {
1118
- mtimeMs = statSync2(filePath).mtimeMs;
1119
- } catch {
1120
- return null;
1121
- }
1122
- const hit = cache2.get(filePath);
1123
- if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
1124
- let skill = null;
1125
- try {
1126
- const parsed = parseFrontmatter(readFileSync2(filePath, "utf8"));
1127
- const name = parsed?.meta.name || dirName;
1128
- const description = parsed?.meta.description;
1129
- if (parsed && NAME_PATTERN.test(name) && description) {
1130
- skill = { name, description, filePath, dir: path6.dirname(filePath), scope };
1131
- }
1132
- } catch {
1133
- skill = null;
1134
- }
1135
- cache2.set(filePath, { mtimeMs, skill });
1136
- return skill;
1137
- }
1138
- function scanDir(base, scope, into) {
1139
- let entries;
1140
- try {
1141
- entries = readdirSync(base);
1142
- } catch {
1143
- return;
1144
- }
1145
- for (const entry of entries.sort()) {
1146
- if (!NAME_PATTERN.test(entry)) continue;
1147
- const parsed = parseSkillFile(path6.join(base, entry, SKILL_FILE), entry, scope);
1148
- if (parsed) into.set(parsed.name, parsed);
1149
- }
1150
- }
1151
- function discoverSkills(cwd, home = starHome()) {
1152
- const byName = /* @__PURE__ */ new Map();
1153
- scanDir(path6.join(home, "skills"), "user", byName);
1154
- scanDir(path6.join(cwd, ".star", "skills"), "project", byName);
1155
- return [...byName.values()];
1156
- }
1157
- function formatSkillsBlock(skills) {
1158
- const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
1159
- return [
1160
- "# Available skills",
1161
- "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
1162
- ...lines
1163
- ].join("\n");
1164
- }
1165
- function loadSkillBody(skill) {
1166
- let parsed = null;
1167
- try {
1168
- parsed = parseFrontmatter(readFileSync2(skill.filePath, "utf8"));
1169
- } catch {
1170
- return null;
1171
- }
1172
- if (!parsed?.body) return null;
1173
- if (parsed.body.length > SKILL_MAX_CHARS) {
1174
- return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
1175
-
1176
- [skill truncated to ${SKILL_MAX_CHARS} characters]`;
1177
- }
1178
- return parsed.body;
1179
- }
1180
- function createSkillTool(deps) {
1181
- return {
1182
- name: "skill",
1183
- 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.",
1184
- permission: "read",
1185
- parameters: z2.object({
1186
- name: z2.string().describe("Name of the skill to load, as listed in the system prompt.")
1187
- }),
1188
- async execute(args) {
1189
- const skills = discoverSkills(deps.cwd, deps.home);
1190
- const skill = skills.find((s) => s.name === args.name);
1191
- if (!skill) {
1192
- const available = skills.map((s) => s.name).join(", ") || "none";
1193
- return {
1194
- content: `Unknown skill: "${args.name}". Available skills: ${available}`,
1195
- isError: true
1196
- };
1197
- }
1198
- const body = loadSkillBody(skill);
1199
- if (!body) {
1200
- return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
1201
- }
1202
- return {
1203
- content: [
1204
- `# Skill: ${skill.name}`,
1205
- "",
1206
- body,
1207
- "",
1208
- `Base directory for relative file references in this skill: ${skill.dir}`
1209
- ].join("\n")
1210
- };
1211
- }
1212
- };
1213
- }
1214
-
1215
- // src/agent/subagent.ts
1216
- import { z as z12 } from "zod";
1217
-
1218
- // src/tools/bash.ts
1219
- import { spawn as spawn3 } from "child_process";
1220
- import { existsSync } from "fs";
1221
- import path7 from "path";
1222
- import { z as z3 } from "zod";
1223
-
1224
- // src/tasks/manager.ts
1225
- import { spawn as spawn2 } from "child_process";
1226
- import { EventEmitter as EventEmitter2 } from "events";
1227
- var MAX_TASK_OUTPUT = 1e5;
1228
- var DEFAULT_TASK_TIMEOUT = 3600;
1229
- var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
1230
- var TaskManager = class extends EventEmitter2 {
1231
- records = /* @__PURE__ */ new Map();
1232
- seq = 0;
1233
- start(opts) {
1234
- const id = `task-${++this.seq}`;
1235
- const spec = resolveShell();
1236
- const child = spawn2(spec.shell, spec.wrap(opts.command), {
1237
- cwd: opts.cwd,
1238
- windowsHide: true
1239
- });
1240
- const rec = {
1241
- id,
1242
- command: opts.command,
1243
- description: opts.description,
1244
- status: "running",
1245
- startedAt: Date.now(),
1246
- output: "",
1247
- truncated: false,
1248
- child
1249
- };
1250
- this.records.set(id, rec);
1251
- const append = (d) => {
1252
- rec.output += d.toString("utf8");
1253
- if (rec.output.length > MAX_TASK_OUTPUT) {
1254
- rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
1255
- rec.truncated = true;
1256
- }
1257
- };
1258
- child.stdout.on("data", append);
1259
- child.stderr.on("data", append);
1260
- child.on("error", (err) => {
1261
- append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
1262
- `));
1263
- if (rec.status === "running") {
1264
- this.finish(rec, "failed", null);
1265
- }
1266
- });
1267
- child.on("close", (code) => {
1268
- if (rec.status !== "running") return;
1269
- this.finish(rec, code === 0 ? "completed" : "failed", code);
1270
- });
1271
- const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
1272
- rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
1273
- rec.timer.unref();
1274
- this.emitUpdate(rec);
1275
- return this.snapshot(rec);
1276
- }
1277
- get(id) {
1278
- const rec = this.records.get(id);
1279
- return rec ? this.snapshot(rec) : void 0;
1280
- }
1281
- list() {
1282
- return [...this.records.values()].map((rec) => this.snapshot(rec));
1283
- }
1284
- runningCount() {
1285
- let count = 0;
1286
- for (const rec of this.records.values()) {
1287
- if (rec.status === "running") count += 1;
1288
- }
1289
- return count;
1290
- }
1291
- kill(id) {
1292
- const rec = this.records.get(id);
1293
- if (!rec || rec.status !== "running") return void 0;
1294
- this.terminate(rec, "stopped");
1295
- return this.snapshot(rec);
1296
- }
1297
- cleanup() {
1298
- const killed = [];
1299
- for (const rec of this.records.values()) {
1300
- if (rec.status === "running") {
1301
- this.terminate(rec, "stopped");
1302
- killed.push(this.snapshot(rec));
1303
- }
1304
- }
1305
- return killed;
1306
- }
1307
- terminate(rec, status) {
1308
- killTree2(rec.child);
1309
- this.finish(rec, status, null);
1310
- }
1311
- finish(rec, status, exitCode) {
1312
- if (rec.timer) clearTimeout(rec.timer);
1313
- rec.status = status;
1314
- rec.exitCode = exitCode;
1315
- rec.endedAt = Date.now();
1316
- this.emitUpdate(rec);
1317
- }
1318
- emitUpdate(rec) {
1319
- this.emit("update", this.snapshot(rec));
1320
- }
1321
- snapshot(rec) {
1322
- return {
1323
- id: rec.id,
1324
- command: rec.command,
1209
+ command: rec.command,
1325
1210
  description: rec.description,
1326
1211
  status: rec.status,
1327
1212
  startedAt: rec.startedAt,
@@ -1335,21 +1220,22 @@ var defaultTaskManager = new TaskManager();
1335
1220
 
1336
1221
  // src/tools/bash.ts
1337
1222
  var MAX_OUTPUT = 3e4;
1223
+ var HALF_OUTPUT = MAX_OUTPUT / 2;
1338
1224
  var DEFAULT_TIMEOUT = 120;
1339
1225
  var MAX_TIMEOUT = 600;
1340
1226
  function findOnPath(exe, exclude) {
1341
- for (const dir of (process.env.PATH ?? "").split(path7.delimiter)) {
1227
+ for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
1342
1228
  if (!dir || exclude?.(dir)) {
1343
1229
  continue;
1344
1230
  }
1345
- const full = path7.join(dir, exe);
1346
- if (existsSync(full)) {
1231
+ const full = path4.join(dir, exe);
1232
+ if (existsSync2(full)) {
1347
1233
  return full;
1348
1234
  }
1349
1235
  }
1350
1236
  return null;
1351
1237
  }
1352
- function resolveShell() {
1238
+ function resolveShellUncached() {
1353
1239
  if (process.platform !== "win32") {
1354
1240
  return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
1355
1241
  }
@@ -1360,8 +1246,8 @@ function resolveShell() {
1360
1246
  }
1361
1247
  const gitExe = findOnPath("git.exe");
1362
1248
  if (gitExe) {
1363
- const sibling = path7.join(path7.dirname(path7.dirname(gitExe)), "bin", "bash.exe");
1364
- if (existsSync(sibling)) {
1249
+ const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
1250
+ if (existsSync2(sibling)) {
1365
1251
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1366
1252
  }
1367
1253
  }
@@ -1370,42 +1256,82 @@ function resolveShell() {
1370
1256
  "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1371
1257
  "D:\\Git\\bin\\bash.exe"
1372
1258
  ]) {
1373
- if (existsSync(candidate)) {
1259
+ if (existsSync2(candidate)) {
1374
1260
  return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1375
1261
  }
1376
1262
  }
1377
1263
  const comspec = process.env.ComSpec ?? "cmd.exe";
1378
1264
  return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
1379
1265
  }
1380
- var schema = z3.object({
1381
- command: z3.string().describe("Shell command to execute"),
1382
- timeout: z3.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1383
- description: z3.string().optional().describe("Short description of what the command does"),
1384
- run_in_background: z3.boolean().optional().describe(
1266
+ var cachedSpec = null;
1267
+ function resolveShell() {
1268
+ if (!cachedSpec) {
1269
+ cachedSpec = resolveShellUncached();
1270
+ }
1271
+ return cachedSpec;
1272
+ }
1273
+ var schema = z.object({
1274
+ command: z.string().describe("Shell command to execute"),
1275
+ timeout: z.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1276
+ description: z.string().optional().describe("Short description of what the command does"),
1277
+ run_in_background: z.boolean().optional().describe(
1385
1278
  "Run the command in the background and return a task id immediately (default false). Use task_output/task_kill to inspect or stop it."
1386
1279
  )
1387
1280
  });
1388
1281
  function killTree2(child) {
1389
- if (process.platform === "win32" && child.pid) {
1390
- spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1391
- windowsHide: true,
1392
- stdio: "ignore"
1393
- }).unref();
1282
+ if (process.platform === "win32") {
1283
+ if (child.pid) {
1284
+ spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1285
+ windowsHide: true,
1286
+ stdio: "ignore"
1287
+ }).unref();
1288
+ }
1394
1289
  return;
1395
1290
  }
1291
+ if (child.pid) {
1292
+ try {
1293
+ process.kill(-child.pid, "SIGKILL");
1294
+ return;
1295
+ } catch {
1296
+ }
1297
+ }
1396
1298
  child.kill("SIGKILL");
1397
1299
  }
1398
- function truncateMiddle(s) {
1399
- if (s.length <= MAX_OUTPUT) {
1400
- return s;
1300
+ var BoundedOutput = class {
1301
+ head = "";
1302
+ tail = "";
1303
+ dropped = 0;
1304
+ overflow = false;
1305
+ append(text) {
1306
+ if (!this.overflow) {
1307
+ this.head += text;
1308
+ if (this.head.length <= MAX_OUTPUT) {
1309
+ return;
1310
+ }
1311
+ this.overflow = true;
1312
+ this.tail = this.head.slice(HALF_OUTPUT);
1313
+ this.head = this.head.slice(0, HALF_OUTPUT);
1314
+ if (this.tail.length > HALF_OUTPUT) {
1315
+ this.dropped = this.tail.length - HALF_OUTPUT;
1316
+ this.tail = this.tail.slice(-HALF_OUTPUT);
1317
+ }
1318
+ return;
1319
+ }
1320
+ this.tail += text;
1321
+ if (this.tail.length > HALF_OUTPUT) {
1322
+ this.dropped += this.tail.length - HALF_OUTPUT;
1323
+ this.tail = this.tail.slice(-HALF_OUTPUT);
1324
+ }
1401
1325
  }
1402
- const half = Math.floor(MAX_OUTPUT / 2);
1403
- const head = s.slice(0, half);
1404
- const tail = s.slice(-half);
1405
- return `${head}
1406
- ... [${s.length - MAX_OUTPUT} characters truncated] ...
1407
- ${tail}`;
1408
- }
1326
+ toString() {
1327
+ if (!this.overflow) {
1328
+ return this.head;
1329
+ }
1330
+ return `${this.head}
1331
+ ... [${this.dropped} characters truncated] ...
1332
+ ${this.tail}`;
1333
+ }
1334
+ };
1409
1335
  var bashTool = {
1410
1336
  name: "bash",
1411
1337
  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.",
@@ -1430,9 +1356,10 @@ description: ${args.description ?? "(none)"}`
1430
1356
  return new Promise((resolve) => {
1431
1357
  const child = spawn3(spec.shell, spec.wrap(args.command), {
1432
1358
  cwd: ctx.cwd,
1433
- windowsHide: true
1359
+ windowsHide: true,
1360
+ detached: process.platform !== "win32"
1434
1361
  });
1435
- let output = "";
1362
+ const output = new BoundedOutput();
1436
1363
  let settled = false;
1437
1364
  const finish = (result) => {
1438
1365
  if (settled) {
@@ -1445,36 +1372,32 @@ description: ${args.description ?? "(none)"}`
1445
1372
  };
1446
1373
  const onAbort = () => {
1447
1374
  killTree2(child);
1448
- finish({ content: `${truncateMiddle(output)}
1375
+ finish({ content: `${output}
1449
1376
  Command aborted`, isError: true });
1450
1377
  };
1451
1378
  const timer = setTimeout(() => {
1452
1379
  killTree2(child);
1453
1380
  finish({
1454
- content: `${truncateMiddle(output)}
1381
+ content: `${output}
1455
1382
  Command timed out after ${timeoutSeconds}s`,
1456
1383
  isError: true
1457
1384
  });
1458
1385
  }, timeoutSeconds * 1e3);
1459
- child.stdout.on("data", (d) => {
1460
- output += d.toString("utf8");
1461
- });
1462
- child.stderr.on("data", (d) => {
1463
- output += d.toString("utf8");
1464
- });
1386
+ timer.unref();
1387
+ child.stdout.on("data", (d) => output.append(d.toString("utf8")));
1388
+ child.stderr.on("data", (d) => output.append(d.toString("utf8")));
1465
1389
  child.on("error", (err) => {
1466
1390
  finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
1467
1391
  });
1468
1392
  ctx.abortSignal?.addEventListener("abort", onAbort);
1469
1393
  child.on("close", (code) => {
1470
- const trimmed = output.replace(/\s+$/, "");
1394
+ const trimmed = output.toString().replace(/\s+$/, "");
1471
1395
  if (code === 0) {
1472
- finish({ content: truncateMiddle(trimmed) || "(no output)" });
1396
+ finish({ content: trimmed || "(no output)" });
1473
1397
  return;
1474
1398
  }
1475
- const body = truncateMiddle(trimmed);
1476
1399
  finish({
1477
- content: `${body}${body ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1400
+ content: `${trimmed}${trimmed ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1478
1401
  isError: true
1479
1402
  });
1480
1403
  });
@@ -1483,14 +1406,14 @@ Command timed out after ${timeoutSeconds}s`,
1483
1406
  };
1484
1407
 
1485
1408
  // src/tools/fs/edit.ts
1486
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1487
- import path8 from "path";
1488
- import { z as z4 } from "zod";
1489
- var schema2 = z4.object({
1490
- path: z4.string().describe("File path, absolute or relative to the working directory"),
1491
- old_string: z4.string().min(1).describe("Exact text to replace"),
1492
- new_string: z4.string().describe("Replacement text"),
1493
- replace_all: z4.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
1409
+ import { readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1410
+ import path5 from "path";
1411
+ import { z as z2 } from "zod";
1412
+ var schema2 = z2.object({
1413
+ path: z2.string().describe("File path, absolute or relative to the working directory"),
1414
+ old_string: z2.string().min(1).describe("Exact text to replace"),
1415
+ new_string: z2.string().describe("Replacement text"),
1416
+ replace_all: z2.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
1494
1417
  });
1495
1418
  var editFileTool = {
1496
1419
  name: "edit_file",
@@ -1498,10 +1421,12 @@ var editFileTool = {
1498
1421
  permission: "write",
1499
1422
  parameters: schema2,
1500
1423
  async execute(args, ctx) {
1501
- const filePath = path8.resolve(ctx.cwd, args.path);
1424
+ const filePath = path5.resolve(ctx.cwd, args.path);
1502
1425
  let content;
1426
+ let readMtimeMs;
1503
1427
  try {
1504
- content = await readFile3(filePath, "utf8");
1428
+ content = await readFile2(filePath, "utf8");
1429
+ readMtimeMs = (await stat2(filePath)).mtimeMs;
1505
1430
  } catch (err) {
1506
1431
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1507
1432
  }
@@ -1520,34 +1445,44 @@ var editFileTool = {
1520
1445
  isError: true
1521
1446
  };
1522
1447
  }
1523
- const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
1448
+ const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, () => args.new_string);
1524
1449
  try {
1525
- await writeFile3(filePath, updated, "utf8");
1450
+ const now = await stat2(filePath).catch(() => null);
1451
+ if (!now || now.mtimeMs !== readMtimeMs) {
1452
+ return {
1453
+ content: `Failed to edit ${args.path}: file changed since it was read; re-read and retry`,
1454
+ isError: true
1455
+ };
1456
+ }
1457
+ await writeFile2(filePath, updated, "utf8");
1526
1458
  } catch (err) {
1527
1459
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1528
1460
  }
1461
+ const contentTooLarge = Buffer.byteLength(content, "utf8") > MAX_SNAPSHOT_CONTENT_BYTES;
1529
1462
  await pushSnapshot({
1530
1463
  id: nextSnapshotId(),
1531
1464
  path: filePath,
1532
1465
  existed: true,
1533
- content,
1466
+ content: contentTooLarge ? null : content,
1467
+ contentTooLarge: contentTooLarge || void 0,
1534
1468
  toolName: "edit_file",
1535
1469
  timestamp: Date.now(),
1536
- turn: currentTurnSeq(),
1537
- messageIndex: currentTurnMessageIndex()
1470
+ turn: ctx.snapshotContext?.turn ?? currentTurnSeq(),
1471
+ messageIndex: ctx.snapshotContext?.messageIndex ?? currentTurnMessageIndex(),
1472
+ owner: ctx.snapshotContext?.owner ?? "root"
1538
1473
  });
1539
1474
  return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
1540
1475
  }
1541
1476
  };
1542
1477
 
1543
1478
  // src/tools/fs/glob.ts
1544
- import path10 from "path";
1545
- import { z as z5 } from "zod";
1479
+ import path7 from "path";
1480
+ import { z as z3 } from "zod";
1546
1481
 
1547
1482
  // src/tools/fs/util.ts
1548
- import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
1549
- import { readdir, stat } from "fs/promises";
1550
- import path9 from "path";
1483
+ import { readFileSync, statSync } from "fs";
1484
+ import { readdir, stat as stat3 } from "fs/promises";
1485
+ import path6 from "path";
1551
1486
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
1552
1487
  async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1553
1488
  const out = [];
@@ -1557,7 +1492,7 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1557
1492
  return;
1558
1493
  }
1559
1494
  for (const entry of entries) {
1560
- const abs = path9.join(dir, entry.name);
1495
+ const abs = path6.join(dir, entry.name);
1561
1496
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1562
1497
  if (entry.isDirectory()) {
1563
1498
  if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
@@ -1568,8 +1503,12 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1568
1503
  if (options.ignore?.(abs, false)) {
1569
1504
  continue;
1570
1505
  }
1506
+ if (options.withMtime === false) {
1507
+ out.push({ abs, rel, mtimeMs: 0 });
1508
+ continue;
1509
+ }
1571
1510
  try {
1572
- const st = await stat(abs);
1511
+ const st = await stat3(abs);
1573
1512
  out.push({ abs, rel, mtimeMs: st.mtimeMs });
1574
1513
  } catch {
1575
1514
  }
@@ -1580,32 +1519,107 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1580
1519
  return out;
1581
1520
  }
1582
1521
  var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
1522
+ function escapeRe(c) {
1523
+ return c.replace(REGEX_SPECIALS, "\\$&");
1524
+ }
1583
1525
  function globToRegExp(pattern) {
1584
- let re = "";
1585
1526
  let i = 0;
1586
- while (i < pattern.length) {
1587
- const c = pattern.charAt(i);
1588
- if (c === "*") {
1589
- if (pattern.charAt(i + 1) === "*") {
1590
- i += 2;
1591
- if (pattern.charAt(i) === "/") {
1592
- i += 1;
1593
- re += "(?:[^/]+/)*";
1527
+ function parseClass() {
1528
+ let j = i + 1;
1529
+ let negate = false;
1530
+ if (pattern.charAt(j) === "!") {
1531
+ negate = true;
1532
+ j += 1;
1533
+ }
1534
+ let body = "";
1535
+ if (pattern.charAt(j) === "]") {
1536
+ body += "\\]";
1537
+ j += 1;
1538
+ }
1539
+ while (j < pattern.length && pattern.charAt(j) !== "]") {
1540
+ const ch = pattern.charAt(j);
1541
+ body += ch === "\\" ? "\\\\" : ch;
1542
+ j += 1;
1543
+ }
1544
+ if (j < pattern.length) {
1545
+ i = j + 1;
1546
+ if (body.startsWith("^")) body = `\\${body}`;
1547
+ return `[${negate ? "^" : ""}${body}]`;
1548
+ }
1549
+ i += 1;
1550
+ return "\\[";
1551
+ }
1552
+ function parseSegment(depth) {
1553
+ let re2 = "";
1554
+ while (i < pattern.length) {
1555
+ const c = pattern.charAt(i);
1556
+ if (depth > 0 && (c === "," || c === "}")) {
1557
+ return { re: re2, end: c };
1558
+ }
1559
+ if (c === "*") {
1560
+ if (pattern.charAt(i + 1) === "*") {
1561
+ i += 2;
1562
+ if (pattern.charAt(i) === "/") {
1563
+ i += 1;
1564
+ re2 += "(?:[^/]+/)*";
1565
+ while (pattern.startsWith("**/", i)) {
1566
+ i += 3;
1567
+ }
1568
+ } else {
1569
+ re2 += ".*";
1570
+ }
1594
1571
  } else {
1595
- re += ".*";
1572
+ re2 += "[^/]*";
1573
+ i += 1;
1596
1574
  }
1597
- } else {
1598
- re += "[^/]*";
1575
+ continue;
1576
+ }
1577
+ if (c === "?") {
1578
+ re2 += "[^/]";
1599
1579
  i += 1;
1580
+ continue;
1600
1581
  }
1601
- } else if (c === "?") {
1602
- re += "[^/]";
1603
- i += 1;
1604
- } else {
1605
- re += c.replace(REGEX_SPECIALS, "\\$&");
1582
+ if (c === "[") {
1583
+ re2 += parseClass();
1584
+ continue;
1585
+ }
1586
+ if (c === "{" && depth < 2) {
1587
+ const close = braceEnd(i);
1588
+ if (close === -1) {
1589
+ re2 += "\\{";
1590
+ i += 1;
1591
+ continue;
1592
+ }
1593
+ i += 1;
1594
+ const alts = [];
1595
+ for (; ; ) {
1596
+ const part = parseSegment(depth + 1);
1597
+ alts.push(part.re);
1598
+ if (part.end !== ",") break;
1599
+ i += 1;
1600
+ }
1601
+ i += 1;
1602
+ re2 += `(?:${alts.join("|")})`;
1603
+ continue;
1604
+ }
1605
+ re2 += escapeRe(c);
1606
1606
  i += 1;
1607
1607
  }
1608
+ return { re: re2, end: "" };
1609
+ }
1610
+ function braceEnd(from) {
1611
+ let depth = 0;
1612
+ for (let j = from; j < pattern.length; j++) {
1613
+ const ch = pattern.charAt(j);
1614
+ if (ch === "{") depth += 1;
1615
+ else if (ch === "}") {
1616
+ depth -= 1;
1617
+ if (depth === 0) return j;
1618
+ }
1619
+ }
1620
+ return -1;
1608
1621
  }
1622
+ const { re } = parseSegment(0);
1609
1623
  return new RegExp(`^${re}$`);
1610
1624
  }
1611
1625
  function matchesGlob(pattern, relPath) {
@@ -1637,10 +1651,10 @@ function parseIgnorePatterns(body) {
1637
1651
  return out;
1638
1652
  }
1639
1653
  function loadIgnorePatterns(dir) {
1640
- const file = path9.join(dir, IGNORE_FILE);
1654
+ const file = path6.join(dir, IGNORE_FILE);
1641
1655
  let mtimeMs;
1642
1656
  try {
1643
- mtimeMs = statSync3(file).mtimeMs;
1657
+ mtimeMs = statSync(file).mtimeMs;
1644
1658
  } catch {
1645
1659
  return [];
1646
1660
  }
@@ -1650,7 +1664,7 @@ function loadIgnorePatterns(dir) {
1650
1664
  }
1651
1665
  let patterns = [];
1652
1666
  try {
1653
- patterns = parseIgnorePatterns(readFileSync3(file, "utf8"));
1667
+ patterns = parseIgnorePatterns(readFileSync(file, "utf8"));
1654
1668
  } catch {
1655
1669
  patterns = [];
1656
1670
  }
@@ -1662,9 +1676,9 @@ function createIgnorePredicate(cwd) {
1662
1676
  if (patterns.length === 0) {
1663
1677
  return void 0;
1664
1678
  }
1665
- const root = path9.resolve(cwd);
1679
+ const root = path6.resolve(cwd);
1666
1680
  return (absPath, isDir) => {
1667
- const rel = path9.relative(root, absPath).split(path9.sep).join("/");
1681
+ const rel = path6.relative(root, absPath).split(path6.sep).join("/");
1668
1682
  if (!rel || rel.startsWith("..")) {
1669
1683
  return false;
1670
1684
  }
@@ -1679,27 +1693,26 @@ function createIgnorePredicate(cwd) {
1679
1693
  return false;
1680
1694
  };
1681
1695
  }
1682
- function isSensitivePath2(filePath) {
1683
- const base = path9.basename(filePath);
1684
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
1685
- return false;
1686
- }
1687
- if (base === ".env" || base.startsWith(".env.")) {
1688
- return true;
1689
- }
1690
- if (base === "id_rsa" || base.endsWith(".pem")) {
1691
- return true;
1692
- }
1693
- return false;
1694
- }
1695
1696
 
1696
1697
  // src/tools/fs/glob.ts
1697
1698
  var MAX_RESULTS = 100;
1698
- var schema3 = z5.object({
1699
- pattern: z5.string().describe(
1700
- "Glob pattern, e.g. 'src/**/*.ts'. Supports **, * and ?. A bare pattern like '*.ts' matches basenames recursively."
1699
+ var GLOB_MAGIC = /[*?[{]/;
1700
+ function literalDirPrefix(pattern) {
1701
+ const segments = pattern.replace(/\\/g, "/").split("/").slice(0, -1);
1702
+ const out = [];
1703
+ for (const segment of segments) {
1704
+ if (segment === "" || GLOB_MAGIC.test(segment)) {
1705
+ break;
1706
+ }
1707
+ out.push(segment);
1708
+ }
1709
+ return out;
1710
+ }
1711
+ var schema3 = z3.object({
1712
+ pattern: z3.string().describe(
1713
+ "Glob pattern, e.g. 'src/**/*.ts'. Supports **, *, ?, {a,b} braces and [abc] character classes. A bare pattern like '*.ts' matches basenames recursively."
1701
1714
  ),
1702
- path: z5.string().optional().describe("Directory to search, defaults to the working directory")
1715
+ path: z3.string().optional().describe("Directory to search, defaults to the working directory")
1703
1716
  });
1704
1717
  var globTool = {
1705
1718
  name: "glob",
@@ -1707,47 +1720,72 @@ var globTool = {
1707
1720
  permission: "read",
1708
1721
  parameters: schema3,
1709
1722
  async execute(args, ctx) {
1710
- const root = path10.resolve(ctx.cwd, args.path ?? ".");
1711
- const files = await walkFiles(root, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1712
- const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
1723
+ const root = path7.resolve(ctx.cwd, args.path ?? ".");
1724
+ const prefixSegments = literalDirPrefix(args.pattern);
1725
+ const walkRoot = prefixSegments.length > 0 ? path7.join(root, ...prefixSegments) : root;
1726
+ const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
1727
+ const files = await walkFiles(walkRoot, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1728
+ const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
1713
1729
  if (matched.length === 0) {
1714
1730
  return { content: `No files matched pattern: ${args.pattern}` };
1715
1731
  }
1716
- return { content: matched.map((f) => f.rel).join("\n") };
1732
+ return { content: matched.map((f) => `${prefix}${f.rel}`).join("\n") };
1717
1733
  }
1718
1734
  };
1719
1735
 
1720
1736
  // src/tools/fs/grep.ts
1721
- import { readFile as readFile4, stat as stat2 } from "fs/promises";
1722
- import path11 from "path";
1723
- import { z as z6 } from "zod";
1737
+ import { readFile as readFile3, stat as stat4 } from "fs/promises";
1738
+ import path8 from "path";
1739
+ import { z as z4 } from "zod";
1724
1740
  var MAX_MATCHES = 250;
1725
- var schema4 = z6.object({
1726
- pattern: z6.string().describe("Regular expression to search for"),
1727
- path: z6.string().optional().describe("File or directory to search, defaults to the working directory"),
1728
- glob: z6.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1729
- ignoreCase: z6.boolean().optional().describe("Case-insensitive matching")
1741
+ var MAX_LINE_CHARS = 500;
1742
+ var MAX_PATTERN_CHARS = 200;
1743
+ var MAX_FILE_BYTES = 5 * 1024 * 1024;
1744
+ function isTooComplexPattern(pattern) {
1745
+ return pattern.length > MAX_PATTERN_CHARS || /\)[+*]/.test(pattern) || /\)\{\d+(,\d*)?\}/.test(pattern);
1746
+ }
1747
+ var schema4 = z4.object({
1748
+ pattern: z4.string().describe("Regular expression to search for"),
1749
+ path: z4.string().optional().describe("File or directory to search, defaults to the working directory"),
1750
+ glob: z4.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1751
+ ignoreCase: z4.boolean().optional().describe("Case-insensitive matching")
1730
1752
  });
1731
1753
  var grepTool = {
1732
1754
  name: "grep",
1733
- description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, node_modules and .git.",
1755
+ description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, files over 5MB, sensitive files, node_modules and .git.",
1734
1756
  permission: "read",
1735
1757
  parameters: schema4,
1736
1758
  async execute(args, ctx) {
1759
+ if (isTooComplexPattern(args.pattern)) {
1760
+ return {
1761
+ content: `pattern too complex: keep it under ${MAX_PATTERN_CHARS} characters and avoid quantified groups like (a+)+`,
1762
+ isError: true
1763
+ };
1764
+ }
1737
1765
  let re;
1738
1766
  try {
1739
1767
  re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
1740
1768
  } catch (err) {
1741
1769
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
1742
1770
  }
1743
- const root = path11.resolve(ctx.cwd, args.path ?? ".");
1771
+ const root = path8.resolve(ctx.cwd, args.path ?? ".");
1744
1772
  let files;
1745
1773
  try {
1746
- const st = await stat2(root);
1774
+ const st = await stat4(root);
1747
1775
  if (st.isFile()) {
1748
- files = [{ abs: root, rel: path11.basename(root), mtimeMs: st.mtimeMs }];
1776
+ if (isSensitivePath(root)) {
1777
+ return {
1778
+ content: `Refused to grep sensitive file: ${args.path ?? "."}`,
1779
+ isError: true
1780
+ };
1781
+ }
1782
+ files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
1749
1783
  } else {
1750
- files = await walkFiles(root, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1784
+ const walked = await walkFiles(root, void 0, {
1785
+ ignore: createIgnorePredicate(ctx.cwd),
1786
+ withMtime: false
1787
+ });
1788
+ files = walked.filter((f) => !isSensitivePath(f.abs));
1751
1789
  }
1752
1790
  } catch {
1753
1791
  return { content: `Path not found: ${args.path ?? "."}`, isError: true };
@@ -1758,10 +1796,19 @@ var grepTool = {
1758
1796
  }
1759
1797
  const out = [];
1760
1798
  let truncated = false;
1799
+ let skippedLarge = 0;
1761
1800
  outer: for (const file of files) {
1801
+ const st = await stat4(file.abs).catch(() => null);
1802
+ if (!st) {
1803
+ continue;
1804
+ }
1805
+ if (st.size > MAX_FILE_BYTES) {
1806
+ skippedLarge += 1;
1807
+ continue;
1808
+ }
1762
1809
  let buf;
1763
1810
  try {
1764
- buf = await readFile4(file.abs);
1811
+ buf = await readFile3(file.abs);
1765
1812
  } catch {
1766
1813
  continue;
1767
1814
  }
@@ -1772,7 +1819,8 @@ var grepTool = {
1772
1819
  for (let i = 0; i < lines.length; i++) {
1773
1820
  const line = lines[i] ?? "";
1774
1821
  if (re.test(line)) {
1775
- out.push(`${file.rel}:${i + 1}:${line}`);
1822
+ const shown = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
1823
+ out.push(`${file.rel}:${i + 1}:${shown}`);
1776
1824
  if (out.length >= MAX_MATCHES) {
1777
1825
  truncated = true;
1778
1826
  break outer;
@@ -1780,26 +1828,55 @@ var grepTool = {
1780
1828
  }
1781
1829
  }
1782
1830
  }
1831
+ const skippedNote = skippedLarge > 0 ? `(skipped ${skippedLarge} file(s) larger than 5MB)` : null;
1783
1832
  if (out.length === 0) {
1784
- return { content: `No matches for pattern: ${args.pattern}` };
1833
+ return {
1834
+ content: `No matches for pattern: ${args.pattern}${skippedNote ? ` ${skippedNote}` : ""}`
1835
+ };
1785
1836
  }
1786
1837
  if (truncated) {
1787
1838
  out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
1788
1839
  }
1840
+ if (skippedNote) {
1841
+ out.push(`... ${skippedNote}`);
1842
+ }
1789
1843
  return { content: out.join("\n") };
1790
1844
  }
1791
1845
  };
1792
1846
 
1793
1847
  // src/tools/fs/read.ts
1794
- import { readFile as readFile5, stat as stat3 } from "fs/promises";
1795
- import path12 from "path";
1796
- import { z as z7 } from "zod";
1848
+ import { open, stat as stat5 } from "fs/promises";
1849
+ import path9 from "path";
1850
+ import { z as z5 } from "zod";
1797
1851
  var DEFAULT_LIMIT = 2e3;
1798
1852
  var MAX_CHARS = 100 * 1024;
1799
- var schema5 = z7.object({
1800
- path: z7.string().describe("File path, absolute or relative to the working directory"),
1801
- offset: z7.number().int().positive().optional().describe("1-based line number to start reading from"),
1802
- limit: z7.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1853
+ var MAX_READ_BYTES = 1024 * 1024;
1854
+ var BINARY_PROBE_BYTES = 8192;
1855
+ function decodeUtf16be(buf) {
1856
+ const len = buf.length - buf.length % 2;
1857
+ const swapped = Buffer.allocUnsafe(len);
1858
+ for (let k = 0; k < len; k += 2) {
1859
+ swapped[k] = buf[k + 1] ?? 0;
1860
+ swapped[k + 1] = buf[k] ?? 0;
1861
+ }
1862
+ return swapped.toString("utf16le");
1863
+ }
1864
+ function decodeTextBuffer(buf) {
1865
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
1866
+ return { text: buf.subarray(2).toString("utf16le") };
1867
+ }
1868
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
1869
+ return { text: decodeUtf16be(buf.subarray(2)) };
1870
+ }
1871
+ if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
1872
+ return { binary: true };
1873
+ }
1874
+ return { text: buf.toString("utf8") };
1875
+ }
1876
+ var schema5 = z5.object({
1877
+ path: z5.string().describe("File path, absolute or relative to the working directory"),
1878
+ offset: z5.number().int().positive().optional().describe("1-based line number to start reading from"),
1879
+ limit: z5.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1803
1880
  });
1804
1881
  var readFileTool = {
1805
1882
  name: "read_file",
@@ -1807,30 +1884,48 @@ var readFileTool = {
1807
1884
  permission: "read",
1808
1885
  parameters: schema5,
1809
1886
  async execute(args, ctx) {
1810
- const filePath = path12.resolve(ctx.cwd, args.path);
1811
- if (isSensitivePath2(filePath)) {
1887
+ const filePath = path9.resolve(ctx.cwd, args.path);
1888
+ if (isSensitivePath(filePath)) {
1812
1889
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1813
1890
  }
1814
- const st = await stat3(filePath).catch(() => null);
1891
+ const st = await stat5(filePath).catch(() => null);
1815
1892
  if (!st) {
1816
1893
  return { content: `File not found: ${args.path}`, isError: true };
1817
1894
  }
1818
1895
  if (st.isDirectory()) {
1819
1896
  return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1820
1897
  }
1821
- const raw = await readFile5(filePath, "utf8");
1822
- const lines = raw.split("\n");
1898
+ const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
1899
+ const fileCapped = st.size > MAX_READ_BYTES;
1900
+ const handle = await open(filePath, "r").catch(() => null);
1901
+ if (!handle) {
1902
+ return { content: `Failed to read ${args.path}`, isError: true };
1903
+ }
1904
+ let buf;
1905
+ try {
1906
+ const { bytesRead, buffer } = await handle.read(Buffer.alloc(bytesToRead), 0, bytesToRead, 0);
1907
+ buf = buffer.subarray(0, bytesRead);
1908
+ } finally {
1909
+ await handle.close().catch(() => {
1910
+ });
1911
+ }
1912
+ const decoded = decodeTextBuffer(buf);
1913
+ if ("binary" in decoded) {
1914
+ return { content: `Cannot read binary file: ${args.path}`, isError: true };
1915
+ }
1916
+ const lines = decoded.text.split("\n");
1917
+ const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
1823
1918
  const start = args.offset ?? 1;
1824
1919
  const limit = args.limit ?? DEFAULT_LIMIT;
1920
+ if (start > lines.length) {
1921
+ return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
1922
+ }
1825
1923
  const end = Math.min(lines.length, start - 1 + limit);
1826
1924
  const out = [];
1827
1925
  let size = 0;
1828
1926
  let truncatedBySize = false;
1829
1927
  for (let i = start - 1; i < end; i++) {
1830
- const line = lines[i];
1831
- if (line === void 0) {
1832
- break;
1833
- }
1928
+ const line = (lines[i] ?? "").replace(/\r$/, "");
1834
1929
  const numbered = `${i + 1} ${line}`;
1835
1930
  if (size + numbered.length > MAX_CHARS) {
1836
1931
  truncatedBySize = true;
@@ -1842,22 +1937,22 @@ var readFileTool = {
1842
1937
  const lastShown = start - 1 + out.length;
1843
1938
  if (truncatedBySize) {
1844
1939
  out.push(
1845
- `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${lines.length})`
1940
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
1846
1941
  );
1847
- } else if (end < lines.length) {
1848
- out.push(`... (truncated: showing lines ${start}-${end} of ${lines.length})`);
1942
+ } else if (end < lines.length || fileCapped) {
1943
+ out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
1849
1944
  }
1850
1945
  return { content: out.join("\n") };
1851
1946
  }
1852
1947
  };
1853
1948
 
1854
1949
  // src/tools/fs/write.ts
1855
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
1856
- import path13 from "path";
1857
- import { z as z8 } from "zod";
1858
- var schema6 = z8.object({
1859
- path: z8.string().describe("File path, absolute or relative to the working directory"),
1860
- content: z8.string().describe("Full content to write to the file")
1950
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
1951
+ import path10 from "path";
1952
+ import { z as z6 } from "zod";
1953
+ var schema6 = z6.object({
1954
+ path: z6.string().describe("File path, absolute or relative to the working directory"),
1955
+ content: z6.string().describe("Full content to write to the file")
1861
1956
  });
1862
1957
  var writeFileTool = {
1863
1958
  name: "write_file",
@@ -1865,11 +1960,11 @@ var writeFileTool = {
1865
1960
  permission: "write",
1866
1961
  parameters: schema6,
1867
1962
  async execute(args, ctx) {
1868
- const filePath = path13.resolve(ctx.cwd, args.path);
1869
- const snapshot = await captureSnapshot(filePath, "write_file");
1963
+ const filePath = path10.resolve(ctx.cwd, args.path);
1964
+ const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
1870
1965
  try {
1871
- await mkdir3(path13.dirname(filePath), { recursive: true });
1872
- await writeFile4(filePath, args.content, "utf8");
1966
+ await mkdir3(path10.dirname(filePath), { recursive: true });
1967
+ await writeFile3(filePath, args.content, "utf8");
1873
1968
  } catch (err) {
1874
1969
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1875
1970
  }
@@ -1879,7 +1974,118 @@ var writeFileTool = {
1879
1974
  };
1880
1975
 
1881
1976
  // src/tools/tasks.ts
1882
- import { z as z9 } from "zod";
1977
+ import { z as z7 } from "zod";
1978
+
1979
+ // src/agent/agent-tasks.ts
1980
+ import { EventEmitter as EventEmitter2 } from "events";
1981
+ var MAX_FINISHED_RECORDS2 = 50;
1982
+ var AgentTaskManager = class extends EventEmitter2 {
1983
+ records = /* @__PURE__ */ new Map();
1984
+ counter = 0;
1985
+ start(run, meta) {
1986
+ const id = `agent-${++this.counter}`;
1987
+ const rec = {
1988
+ id,
1989
+ prompt: meta.prompt,
1990
+ description: meta.description,
1991
+ status: "running",
1992
+ startedAt: Date.now(),
1993
+ result: "",
1994
+ controller: new AbortController(),
1995
+ notified: false
1996
+ };
1997
+ this.records.set(id, rec);
1998
+ void run(rec.controller.signal).then((result) => this.finish(rec, "completed", result)).catch(
1999
+ (error) => this.finish(rec, "failed", error instanceof Error ? error.message : String(error))
2000
+ );
2001
+ this.emitUpdate(rec);
2002
+ return this.snapshot(rec);
2003
+ }
2004
+ get(id) {
2005
+ const rec = this.records.get(id);
2006
+ return rec ? this.snapshot(rec) : void 0;
2007
+ }
2008
+ list() {
2009
+ return [...this.records.values()].map((rec) => this.snapshot(rec));
2010
+ }
2011
+ runningCount() {
2012
+ let count = 0;
2013
+ for (const rec of this.records.values()) {
2014
+ if (rec.status === "running") count += 1;
2015
+ }
2016
+ return count;
2017
+ }
2018
+ kill(id) {
2019
+ const rec = this.records.get(id);
2020
+ if (!rec || rec.status !== "running") return void 0;
2021
+ rec.controller.abort();
2022
+ this.finish(rec, "stopped", rec.result || "Stopped by user.");
2023
+ return this.snapshot(rec);
2024
+ }
2025
+ cleanup() {
2026
+ const killed = [];
2027
+ for (const rec of this.records.values()) {
2028
+ if (rec.status === "running") {
2029
+ rec.controller.abort();
2030
+ this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
2031
+ killed.push(this.snapshot(rec));
2032
+ }
2033
+ }
2034
+ return killed;
2035
+ }
2036
+ // Finished tasks whose result has not yet been handed to the parent loop.
2037
+ drainNotifications() {
2038
+ const drained = [];
2039
+ for (const rec of this.records.values()) {
2040
+ if (rec.status !== "running" && !rec.notified) {
2041
+ rec.notified = true;
2042
+ drained.push(this.snapshot(rec));
2043
+ }
2044
+ }
2045
+ this.pruneFinished();
2046
+ return drained;
2047
+ }
2048
+ // Map iteration is insertion-ordered, so the first finished records met are
2049
+ // the oldest. Never drop an un-notified record: the parent has not seen its
2050
+ // result yet. Running records are untouched.
2051
+ pruneFinished() {
2052
+ let finished = 0;
2053
+ for (const rec of this.records.values()) {
2054
+ if (rec.status !== "running") finished += 1;
2055
+ }
2056
+ let excess = finished - MAX_FINISHED_RECORDS2;
2057
+ if (excess <= 0) return;
2058
+ for (const rec of this.records.values()) {
2059
+ if (excess <= 0) return;
2060
+ if (rec.status !== "running" && rec.notified) {
2061
+ this.records.delete(rec.id);
2062
+ excess -= 1;
2063
+ }
2064
+ }
2065
+ }
2066
+ finish(rec, status, result) {
2067
+ if (rec.status !== "running") return;
2068
+ rec.status = status;
2069
+ rec.result = result;
2070
+ rec.endedAt = Date.now();
2071
+ this.emitUpdate(rec);
2072
+ }
2073
+ emitUpdate(rec) {
2074
+ this.emit("update", this.snapshot(rec));
2075
+ }
2076
+ snapshot(rec) {
2077
+ return {
2078
+ id: rec.id,
2079
+ prompt: rec.prompt,
2080
+ description: rec.description,
2081
+ status: rec.status,
2082
+ startedAt: rec.startedAt,
2083
+ endedAt: rec.endedAt,
2084
+ result: rec.result
2085
+ };
2086
+ }
2087
+ };
2088
+ var defaultAgentTasks = new AgentTaskManager();
1883
2089
 
1884
2090
  // src/tasks/format.ts
1885
2091
  function formatDuration(startedAt, endedAt) {
@@ -1925,9 +2131,14 @@ function formatTaskFinished(task) {
1925
2131
  }
1926
2132
 
1927
2133
  // src/tools/tasks.ts
1928
- var idSchema = z9.object({
1929
- id: z9.string().describe("Task id, e.g. task-1 or agent-1")
2134
+ var idSchema = z7.object({
2135
+ id: z7.string().describe("Task id, e.g. task-1 or agent-1")
1930
2136
  });
2137
+ function truncateOutput(s) {
2138
+ if (s.length <= MAX_OUTPUT) return s;
2139
+ return `[... earlier output truncated; showing the last ${MAX_OUTPUT} characters ...]
2140
+ ${s.slice(-MAX_OUTPUT)}`;
2141
+ }
1931
2142
  function formatAgentTaskLine(task) {
1932
2143
  const duration = formatDuration(task.startedAt, task.endedAt);
1933
2144
  const label = task.description ?? task.prompt.slice(0, 60);
@@ -1938,7 +2149,7 @@ var taskListTool = {
1938
2149
  name: "task_list",
1939
2150
  description: "List background tasks (shell commands and subagents) with status and runtime.",
1940
2151
  permission: "read",
1941
- parameters: z9.object({}),
2152
+ parameters: z7.object({}),
1942
2153
  execute() {
1943
2154
  const shell = formatTaskList(defaultTaskManager.list());
1944
2155
  const agents = defaultAgentTasks.list();
@@ -1956,13 +2167,13 @@ var taskOutputTool = {
1956
2167
  execute(args) {
1957
2168
  const shell = defaultTaskManager.get(args.id);
1958
2169
  if (shell) {
1959
- const body = shell.output.replace(/\s+$/, "") || "(no output yet)";
2170
+ const body = truncateOutput(shell.output.replace(/\s+$/, "") || "(no output yet)");
1960
2171
  return Promise.resolve({ content: `${formatTaskLine(shell)}
1961
2172
  ${body}` });
1962
2173
  }
1963
2174
  const agent = defaultAgentTasks.get(args.id);
1964
2175
  if (agent) {
1965
- const body = agent.status === "running" ? "(still running)" : agent.result.replace(/\s+$/, "") || "(no report)";
2176
+ const body = agent.status === "running" ? "(still running)" : truncateOutput(agent.result.replace(/\s+$/, "") || "(no report)");
1966
2177
  return Promise.resolve({ content: `${formatAgentTaskLine(agent)}
1967
2178
  ${body}` });
1968
2179
  }
@@ -1987,26 +2198,159 @@ var taskKillTool = {
1987
2198
  isError: true
1988
2199
  });
1989
2200
  }
1990
- if (shell) {
1991
- defaultTaskManager.kill(args.id);
1992
- } else {
1993
- defaultAgentTasks.kill(args.id);
2201
+ if (shell) {
2202
+ defaultTaskManager.kill(args.id);
2203
+ } else {
2204
+ defaultAgentTasks.kill(args.id);
2205
+ }
2206
+ return Promise.resolve({ content: `Task ${args.id} stopped.` });
2207
+ }
2208
+ };
2209
+
2210
+ // src/tools/todo.ts
2211
+ import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
2212
+ import path11 from "path";
2213
+ import { z as z8 } from "zod";
2214
+ var todoItemSchema = z8.object({
2215
+ id: z8.number().int(),
2216
+ title: z8.string(),
2217
+ status: z8.enum(["pending", "in_progress", "done"])
2218
+ });
2219
+ var writeSchema = z8.object({
2220
+ todos: z8.array(todoItemSchema).describe("The full todo list, replacing the current one")
2221
+ });
2222
+ var readSchema = z8.object({});
2223
+ function fileFor(cwd) {
2224
+ return path11.join(cwd, ".star", "todos.json");
2225
+ }
2226
+ var persistGuard = () => true;
2227
+ function setTodoPersistGuard(guard) {
2228
+ persistGuard = guard;
2229
+ }
2230
+ var TodoStore = class {
2231
+ items = /* @__PURE__ */ new Map();
2232
+ list() {
2233
+ return [...this.items.values()];
2234
+ }
2235
+ replace(items) {
2236
+ this.items.clear();
2237
+ for (const item of items) {
2238
+ this.items.set(item.id, item);
2239
+ }
2240
+ }
2241
+ async load(cwd) {
2242
+ let raw;
2243
+ try {
2244
+ raw = await readFile4(fileFor(cwd), "utf8");
2245
+ } catch {
2246
+ return;
2247
+ }
2248
+ try {
2249
+ const parsed = JSON.parse(raw);
2250
+ if (Array.isArray(parsed)) {
2251
+ this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
2252
+ }
2253
+ } catch {
2254
+ }
2255
+ }
2256
+ // Atomic write (tmp + rename): a crash or failed save can never leave a
2257
+ // truncated todos.json behind.
2258
+ async save(cwd) {
2259
+ if (!persistGuard()) {
2260
+ return;
2261
+ }
2262
+ await mkdir4(path11.join(cwd, ".star"), { recursive: true });
2263
+ const file = fileFor(cwd);
2264
+ const tmp = `${file}.tmp`;
2265
+ await writeFile4(tmp, `${JSON.stringify(this.list(), null, 2)}
2266
+ `);
2267
+ await rename(tmp, file);
2268
+ }
2269
+ };
2270
+ var SYMBOLS = {
2271
+ pending: "[ ]",
2272
+ in_progress: "[~]",
2273
+ done: "[x]"
2274
+ };
2275
+ function formatTodos(items) {
2276
+ if (items.length === 0) {
2277
+ return "No todos.";
2278
+ }
2279
+ const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
2280
+ const width = Math.max(...lines.map((l) => l.length));
2281
+ return lines.map((line, i) => {
2282
+ const status = items[i]?.status;
2283
+ return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
2284
+ }).join("\n");
2285
+ }
2286
+ var defaultStore = new TodoStore();
2287
+ function parseTodoArgs(args) {
2288
+ const parsed = writeSchema.safeParse(args);
2289
+ return parsed.success ? parsed.data.todos : null;
2290
+ }
2291
+ function pendingTodoTitles(args) {
2292
+ return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
2293
+ }
2294
+ async function loadTodos(cwd) {
2295
+ await defaultStore.load(cwd);
2296
+ return defaultStore.list();
2297
+ }
2298
+ function resetTodos() {
2299
+ defaultStore.replace([]);
2300
+ }
2301
+ function createTodoTools(store = defaultStore) {
2302
+ const todoWrite = {
2303
+ name: "todo_write",
2304
+ description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
2305
+ permission: "read",
2306
+ parameters: writeSchema,
2307
+ async execute(args, ctx) {
2308
+ const parsed = writeSchema.safeParse(args);
2309
+ if (!parsed.success) {
2310
+ return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
2311
+ }
2312
+ const previous = store.list();
2313
+ store.replace(parsed.data.todos);
2314
+ try {
2315
+ await store.save(ctx.cwd);
2316
+ } catch (err) {
2317
+ store.replace(previous);
2318
+ return { content: `Failed to save todos: ${err.message}`, isError: true };
2319
+ }
2320
+ const items = store.list();
2321
+ const done = items.filter((it) => it.status === "done").length;
2322
+ return { content: `Todo list updated (${done}/${items.length} done).` };
2323
+ }
2324
+ };
2325
+ const todoRead = {
2326
+ name: "todo_read",
2327
+ description: "Read the current todo list.",
2328
+ permission: "read",
2329
+ parameters: readSchema,
2330
+ async execute() {
2331
+ return { content: formatTodos(store.list()) };
1994
2332
  }
1995
- return Promise.resolve({ content: `Task ${args.id} stopped.` });
1996
- }
1997
- };
2333
+ };
2334
+ return [todoWrite, todoRead];
2335
+ }
1998
2336
 
1999
2337
  // src/tools/web/fetch.ts
2000
- import { z as z10 } from "zod";
2338
+ import { z as z9 } from "zod";
2001
2339
  var DEFAULT_MAX_CHARS = 2e4;
2002
2340
  var MAX_BYTES = 2 * 1024 * 1024;
2003
2341
  var TIMEOUT_MS = 3e4;
2004
- var schema7 = z10.object({
2005
- url: z10.string().describe("The http(s) URL to fetch"),
2006
- maxChars: z10.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
2342
+ var schema7 = z9.object({
2343
+ url: z9.string().describe("The http(s) URL to fetch"),
2344
+ maxChars: z9.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
2007
2345
  });
2346
+ function safeCodePoint(n, raw) {
2347
+ if (!Number.isInteger(n) || n < 0 || n > 1114111) {
2348
+ return raw;
2349
+ }
2350
+ return String.fromCodePoint(n);
2351
+ }
2008
2352
  function decodeEntities(s) {
2009
- 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) => {
2353
+ return s.replace(/&#(\d+);/g, (m, n) => safeCodePoint(Number(n), m)).replace(/&#x([0-9a-fA-F]+);/gi, (m, n) => safeCodePoint(Number.parseInt(n, 16), m)).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
2010
2354
  switch (m) {
2011
2355
  case "&quot;":
2012
2356
  return '"';
@@ -2066,7 +2410,7 @@ function truncate2(s, maxChars) {
2066
2410
  }
2067
2411
  var webFetchTool = {
2068
2412
  name: "web_fetch",
2069
- 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).",
2413
+ 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). Can reach internal/intranet addresses, so do not use it for untrusted URLs.",
2070
2414
  permission: "read",
2071
2415
  parameters: schema7,
2072
2416
  async execute(args, ctx) {
@@ -2131,15 +2475,15 @@ var webFetchTool = {
2131
2475
  };
2132
2476
 
2133
2477
  // src/tools/web/search.ts
2134
- import { z as z11 } from "zod";
2478
+ import { z as z10 } from "zod";
2135
2479
  var TIMEOUT_MS2 = 15e3;
2136
2480
  var DEFAULT_MAX_RESULTS = 5;
2137
2481
  var MAX_RESULTS_LIMIT = 10;
2138
2482
  var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
2139
2483
  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";
2140
- var schema8 = z11.object({
2141
- query: z11.string().min(1).describe("The search query"),
2142
- maxResults: z11.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
2484
+ var schema8 = z10.object({
2485
+ query: z10.string().min(1).describe("The search query"),
2486
+ maxResults: z10.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
2143
2487
  `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
2144
2488
  )
2145
2489
  });
@@ -2248,7 +2592,9 @@ var webSearchTool = {
2248
2592
  // src/tools/registry.ts
2249
2593
  var ToolRegistry = class {
2250
2594
  tools = /* @__PURE__ */ new Map();
2251
- constructor() {
2595
+ // todoStore defaults to the module-level shared store; subagent loops pass
2596
+ // their own instance so a child's todo_write cannot clobber the parent list.
2597
+ constructor(todoStore) {
2252
2598
  for (const tool of [
2253
2599
  readFileTool,
2254
2600
  writeFileTool,
@@ -2261,7 +2607,7 @@ var ToolRegistry = class {
2261
2607
  taskListTool,
2262
2608
  taskOutputTool,
2263
2609
  taskKillTool,
2264
- ...createTodoTools()
2610
+ ...createTodoTools(todoStore)
2265
2611
  ]) {
2266
2612
  this.register(tool);
2267
2613
  }
@@ -2280,13 +2626,165 @@ var ToolRegistry = class {
2280
2626
  }
2281
2627
  };
2282
2628
 
2283
- // src/tools/index.ts
2284
- function createDefaultRegistry() {
2285
- const registry = new ToolRegistry();
2286
- for (const tool of createTodoTools()) {
2287
- registry.register(tool);
2629
+ // src/agent/project-memory.ts
2630
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
2631
+ import path12 from "path";
2632
+ var PROJECT_MEMORY_FILE = "AGENTS.md";
2633
+ var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
2634
+ var cache = /* @__PURE__ */ new Map();
2635
+ function readProjectMemory(cwd) {
2636
+ const file = path12.join(cwd, PROJECT_MEMORY_FILE);
2637
+ let mtimeMs;
2638
+ try {
2639
+ mtimeMs = statSync2(file).mtimeMs;
2640
+ } catch {
2641
+ return null;
2642
+ }
2643
+ const hit = cache.get(file);
2644
+ if (hit && hit.mtimeMs === mtimeMs) return hit.block;
2645
+ let block = null;
2646
+ try {
2647
+ let body = readFileSync2(file, "utf8").trim();
2648
+ if (body) {
2649
+ if (body.length > PROJECT_MEMORY_MAX_CHARS) {
2650
+ body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
2651
+
2652
+ [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
2653
+ }
2654
+ block = `# Project instructions (AGENTS.md)
2655
+ ${body}`;
2656
+ }
2657
+ } catch {
2658
+ block = null;
2659
+ }
2660
+ cache.set(file, { mtimeMs, block });
2661
+ return block;
2662
+ }
2663
+
2664
+ // src/agent/skills.ts
2665
+ import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
2666
+ import path13 from "path";
2667
+ import { z as z11 } from "zod";
2668
+ var SKILL_FILE = "SKILL.md";
2669
+ var SKILL_MAX_CHARS = 32 * 1024;
2670
+ var NAME_PATTERN = /^[a-z0-9-]+$/;
2671
+ function parseFrontmatter(raw) {
2672
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
2673
+ if (!match) return null;
2674
+ const meta = {};
2675
+ for (const line of (match[1] ?? "").split(/\r?\n/)) {
2676
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
2677
+ if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
2678
+ }
2679
+ return { meta, body: raw.slice(match[0].length).trim() };
2680
+ }
2681
+ var cache2 = /* @__PURE__ */ new Map();
2682
+ function parseSkillFile(filePath, dirName, scope) {
2683
+ let mtimeMs;
2684
+ try {
2685
+ mtimeMs = statSync3(filePath).mtimeMs;
2686
+ } catch {
2687
+ return null;
2688
+ }
2689
+ const hit = cache2.get(filePath);
2690
+ if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
2691
+ let skill = null;
2692
+ try {
2693
+ const parsed = parseFrontmatter(readFileSync3(filePath, "utf8"));
2694
+ const name = parsed?.meta.name || dirName;
2695
+ const description = parsed?.meta.description;
2696
+ if (parsed && NAME_PATTERN.test(name) && description) {
2697
+ skill = { name, description, filePath, dir: path13.dirname(filePath), scope };
2698
+ }
2699
+ } catch {
2700
+ skill = null;
2701
+ }
2702
+ cache2.set(filePath, { mtimeMs, skill });
2703
+ return skill;
2704
+ }
2705
+ function scanDir(base, scope, into) {
2706
+ let entries;
2707
+ try {
2708
+ entries = readdirSync(base);
2709
+ } catch {
2710
+ return;
2711
+ }
2712
+ for (const entry of entries.sort()) {
2713
+ if (!NAME_PATTERN.test(entry)) continue;
2714
+ const parsed = parseSkillFile(path13.join(base, entry, SKILL_FILE), entry, scope);
2715
+ if (parsed) into.set(parsed.name, parsed);
2716
+ }
2717
+ }
2718
+ function discoverSkills(cwd, home = starHome()) {
2719
+ const byName = /* @__PURE__ */ new Map();
2720
+ scanDir(path13.join(home, "skills"), "user", byName);
2721
+ scanDir(path13.join(cwd, ".star", "skills"), "project", byName);
2722
+ return [...byName.values()];
2723
+ }
2724
+ function formatSkillsBlock(skills) {
2725
+ const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
2726
+ return [
2727
+ "# Available skills",
2728
+ "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
2729
+ ...lines
2730
+ ].join("\n");
2731
+ }
2732
+ function loadSkillBody(skill) {
2733
+ let parsed = null;
2734
+ try {
2735
+ parsed = parseFrontmatter(readFileSync3(skill.filePath, "utf8"));
2736
+ } catch {
2737
+ return null;
2738
+ }
2739
+ if (!parsed?.body) return null;
2740
+ if (parsed.body.length > SKILL_MAX_CHARS) {
2741
+ return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
2742
+
2743
+ [skill truncated to ${SKILL_MAX_CHARS} characters]`;
2288
2744
  }
2289
- return registry;
2745
+ return parsed.body;
2746
+ }
2747
+ function createSkillTool(deps) {
2748
+ return {
2749
+ name: "skill",
2750
+ 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.",
2751
+ permission: "read",
2752
+ parameters: z11.object({
2753
+ name: z11.string().describe("Name of the skill to load, as listed in the system prompt.")
2754
+ }),
2755
+ async execute(args) {
2756
+ const skills = discoverSkills(deps.cwd, deps.home);
2757
+ const skill = skills.find((s) => s.name === args.name);
2758
+ if (!skill) {
2759
+ const available = skills.map((s) => s.name).join(", ") || "none";
2760
+ return {
2761
+ content: `Unknown skill: "${args.name}". Available skills: ${available}`,
2762
+ isError: true
2763
+ };
2764
+ }
2765
+ const body = loadSkillBody(skill);
2766
+ if (!body) {
2767
+ return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
2768
+ }
2769
+ return {
2770
+ content: [
2771
+ `# Skill: ${skill.name}`,
2772
+ "",
2773
+ body,
2774
+ "",
2775
+ `Base directory for relative file references in this skill: ${skill.dir}`
2776
+ ].join("\n")
2777
+ };
2778
+ }
2779
+ };
2780
+ }
2781
+
2782
+ // src/agent/subagent.ts
2783
+ import { z as z12 } from "zod";
2784
+
2785
+ // src/tools/index.ts
2786
+ function createDefaultRegistry(todoStore) {
2787
+ return new ToolRegistry(todoStore);
2290
2788
  }
2291
2789
 
2292
2790
  // src/agent/subagent.ts
@@ -2294,7 +2792,7 @@ var MAX_SUBAGENT_DEPTH = 1;
2294
2792
  var MAX_REPORT_CHARS = 2e4;
2295
2793
  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.";
2296
2794
  async function runSubagent(deps, args, cwd, signal) {
2297
- const { AgentLoop: AgentLoop2 } = await import("./loop-ED6AAMWV.js");
2795
+ const { AgentLoop: AgentLoop2 } = await import("./loop-TQ7GFTTE.js");
2298
2796
  const child = new AgentLoop2({
2299
2797
  model: deps.model,
2300
2798
  registry: createDefaultRegistry(),
@@ -2321,6 +2819,9 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2321
2819
  } catch (e) {
2322
2820
  error = e instanceof Error ? e.message : String(e);
2323
2821
  }
2822
+ if (signal.aborted) {
2823
+ throw new Error("Tool execution aborted.");
2824
+ }
2324
2825
  let report = "";
2325
2826
  const messages = child.getMessages();
2326
2827
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2440,6 +2941,9 @@ var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task us
2440
2941
  var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?|I will|I'll|I am going to|I'm going to|I shall|let me|next,? I|I will now)/i;
2441
2942
  var AUTO_CONTINUE_NUDGE = "[auto-continue] You ended your turn with words instead of actions. Do not describe or restate the plan \u2014 continue the task NOW by calling tools. Reply with text only if the task is already fully complete.";
2442
2943
  var EMPTY_REPLY_NUDGE = "[auto-continue] Your previous reply was empty \u2014 no text and no tool calls. If the task is fully complete, reply with one short confirmation; otherwise continue NOW by calling tools.";
2944
+ function isFilteredEmptyFinish(finishReason) {
2945
+ return finishReason === "content-filter";
2946
+ }
2443
2947
  function isSteerableEmptyFinish(finishReason) {
2444
2948
  return finishReason === "stop" || finishReason === "content-filter";
2445
2949
  }
@@ -2481,20 +2985,23 @@ ${finalReply.slice(0, 2e3)}
2481
2985
  }
2482
2986
  }
2483
2987
  var STREAM_RETRY_BASE_DELAY_MS = 1e3;
2484
- var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
2485
- "AI_NoSuchToolError",
2486
- "AI_InvalidToolArgumentsError",
2487
- "AI_InvalidPromptError",
2488
- "AI_NoSuchModelError",
2489
- "AI_LoadAPIKeyError"
2490
- ]);
2491
- function isRetryableStreamError(error) {
2492
- if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) return false;
2493
- const status = error.statusCode;
2494
- if (typeof status === "number") {
2495
- return status === 408 || status === 429 || status >= 500;
2496
- }
2497
- return true;
2988
+ var REDO_LABEL_MAX = 40;
2989
+ function messageText(message) {
2990
+ if (!message) return "";
2991
+ const content = message.content;
2992
+ if (typeof content === "string") return content;
2993
+ if (Array.isArray(content)) {
2994
+ return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
2995
+ }
2996
+ return "";
2997
+ }
2998
+ function stableStringify(value) {
2999
+ if (value === void 0) return "undefined";
3000
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
3001
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
3002
+ const record = value;
3003
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
3004
+ return `{${entries.join(",")}}`;
2498
3005
  }
2499
3006
  function sleep(ms, signal) {
2500
3007
  if (signal.aborted) return Promise.resolve(false);
@@ -2514,11 +3021,32 @@ var AgentLoop = class {
2514
3021
  messages = [];
2515
3022
  opts;
2516
3023
  // Seq + user-message index of each turn started via stream(); lets /undo
2517
- // match a retracted turn to the file snapshots it produced. Cleared when
2518
- // history is replaced wholesale (resume/compact), because indices no longer
2519
- // line up — in that case /undo only retracts messages, never wrong files.
3024
+ // match a retracted turn to the file snapshots it produced. `tree` is the
3025
+ // whole-tree git snapshot captured at the turn's start (root loop only,
3026
+ // config.gitSnapshots) — when present, /undo restores it instead of
3027
+ // replaying per-file snapshots, which also covers bash-made changes.
3028
+ // Cleared when history is replaced wholesale (resume/compact), because
3029
+ // indices no longer line up — in that case /undo only retracts messages,
3030
+ // never wrong files.
2520
3031
  turnMarkers = [];
3032
+ // Pre-undo tree snapshots, latest last: a git-path /undo pushes the
3033
+ // pre-undo working tree here so /redo can restore it exactly. Only the git
3034
+ // path feeds this — per-file snapshot undos stay non-redoable.
3035
+ redoStack = [];
3036
+ // Start of the most recently retracted turn's user message; undoLastTurn
3037
+ // consumes it as the redo label right after retractLastTurn sets it.
3038
+ lastUndoneLabel = "";
3039
+ // Doom-loop guard: signature of the most recent tool call and how many
3040
+ // times in a row it has repeated. Any different call resets the count.
3041
+ lastToolSignature = null;
3042
+ repeatedToolCalls = 0;
3043
+ // Turn context of the in-flight stream() turn, stamped onto every tool
3044
+ // execution so file snapshots attribute root changes to a turn and mark
3045
+ // subagent changes as excluded from turn-level retraction.
3046
+ activeTurnSeq = 0;
3047
+ activeTurnUserIndex = -1;
2521
3048
  titleScheduled = false;
3049
+ titleInput;
2522
3050
  confirmHandler;
2523
3051
  // Hook failures never block the turn (except an explicit PreToolUse block);
2524
3052
  // their stderr surfaces through this callback (REPL system message / stderr
@@ -2526,6 +3054,15 @@ var AgentLoop = class {
2526
3054
  onHookWarning;
2527
3055
  constructor(opts) {
2528
3056
  this.opts = opts;
3057
+ const depth = opts.subagentDepth ?? 0;
3058
+ if (depth > 0) {
3059
+ opts.registry = new ToolRegistry(new TodoStore());
3060
+ } else {
3061
+ setTodoPersistGuard(() => {
3062
+ const mode = this.opts.config.permissionMode;
3063
+ return mode !== "readonly" && mode !== "plan";
3064
+ });
3065
+ }
2529
3066
  if (opts.system) {
2530
3067
  this.messages.push({ role: "system", content: opts.system });
2531
3068
  }
@@ -2534,7 +3071,6 @@ var AgentLoop = class {
2534
3071
  }
2535
3072
  opts.registry?.register(createSkillTool({ cwd: opts.cwd }));
2536
3073
  opts.registry?.register(createRememberTool());
2537
- const depth = opts.subagentDepth ?? 0;
2538
3074
  if (opts.registry && depth < MAX_SUBAGENT_DEPTH) {
2539
3075
  opts.registry.register(
2540
3076
  createSubagentTool({
@@ -2553,10 +3089,14 @@ var AgentLoop = class {
2553
3089
  }
2554
3090
  // Swaps the persistence target (/new starts a fresh session mid-REPL).
2555
3091
  // Title generation is re-armed so the new session gets one after its first
2556
- // turn, and snapshot checkpoints now flow to the new store.
3092
+ // turn, and snapshot checkpoints now flow to the new store. The redo stack
3093
+ // dies with the old session context: its trees describe working-tree states
3094
+ // that no longer line up with the new session's conversation.
2557
3095
  setSessionStore(store) {
2558
3096
  this.opts.sessionStore = store;
2559
3097
  this.titleScheduled = false;
3098
+ this.titleInput = void 0;
3099
+ this.redoStack = [];
2560
3100
  if (store) this.bindSnapshotHooks(store);
2561
3101
  }
2562
3102
  bindSnapshotHooks(store) {
@@ -2569,7 +3109,9 @@ var AgentLoop = class {
2569
3109
  existed: snapshot.existed,
2570
3110
  toolName: snapshot.toolName,
2571
3111
  turn: snapshot.turn,
2572
- messageIndex: snapshot.messageIndex
3112
+ messageIndex: snapshot.messageIndex,
3113
+ owner: snapshot.owner,
3114
+ contentTooLarge: snapshot.contentTooLarge
2573
3115
  },
2574
3116
  snapshot.content
2575
3117
  ),
@@ -2579,37 +3121,92 @@ var AgentLoop = class {
2579
3121
  async loadMessages(messages) {
2580
3122
  this.messages = reconcileToolCalls(messages);
2581
3123
  this.turnMarkers = [];
3124
+ this.redoStack = [];
2582
3125
  }
2583
3126
  // Read-only counterpart of retractLastTurn: how many messages /undo would
2584
- // drop and which snapshot turn's file changes it would revert (undefined =
2585
- // no verified turn, so no file snapshots may be reverted). Mutates nothing,
2586
- // so the REPL can show the preview before the user confirms.
3127
+ // drop, which snapshot turn's file changes it would revert (undefined =
3128
+ // no verified turn, so no file snapshots may be reverted), and the turn's
3129
+ // whole-tree snapshot when one was captured. Mutates nothing, so the REPL
3130
+ // can show the preview before the user confirms.
2587
3131
  previewLastTurnRetraction() {
2588
3132
  const result = retractLastTurn([...this.messages]);
2589
3133
  if (result.removed === 0) return { removed: 0 };
2590
3134
  const userIndex = result.messages.length;
2591
3135
  const last = this.turnMarkers[this.turnMarkers.length - 1];
2592
- const turn = last && last.userIndex === userIndex ? last.seq : void 0;
2593
- return { removed: result.removed, turn };
3136
+ const verified = last && last.userIndex === userIndex;
3137
+ return {
3138
+ removed: result.removed,
3139
+ turn: verified ? last.seq : void 0,
3140
+ tree: verified ? last.tree : void 0
3141
+ };
2594
3142
  }
2595
3143
  // Drops the final user message and everything after it, and persists the
2596
3144
  // trimmed history so a later /resume does not bring the turn back.
2597
3145
  // `turn` is the retracted turn's snapshot seq when it could be verified
2598
3146
  // against the marker recorded at turn start — undefined means the caller
2599
- // must not revert any file snapshots.
3147
+ // must not revert any file snapshots. `tree` is the turn's whole-tree
3148
+ // snapshot when git snapshots tracked it.
2600
3149
  async retractLastTurn() {
2601
3150
  const result = retractLastTurn([...this.messages]);
2602
3151
  if (result.removed === 0) return { removed: 0 };
2603
3152
  const userIndex = result.messages.length;
3153
+ this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
2604
3154
  this.messages = result.messages;
2605
3155
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2606
3156
  const last = this.turnMarkers[this.turnMarkers.length - 1];
2607
3157
  let turn;
3158
+ let tree;
2608
3159
  if (last && last.userIndex === userIndex) {
2609
3160
  turn = last.seq;
3161
+ tree = last.tree;
2610
3162
  this.turnMarkers.pop();
2611
3163
  }
2612
- return { removed: result.removed, turn };
3164
+ return { removed: result.removed, turn, tree };
3165
+ }
3166
+ // The confirmed /undo: retract the last turn, then revert its file changes.
3167
+ // With a whole-tree snapshot the working tree is restored from git — which
3168
+ // also reverts changes no tool snapshot saw (bash edits, deleted files) —
3169
+ // and the turn's per-file snapshots are dropped instead of replayed (they
3170
+ // would double-restore). The pre-undo tree feeds the redo stack, so /redo
3171
+ // returns to the exact state before the undo. Any git failure falls back
3172
+ // to the per-file snapshot revert, which never pushes the redo stack.
3173
+ async undoLastTurn() {
3174
+ const { removed, turn, tree } = await this.retractLastTurn();
3175
+ if (removed === 0) return { removed, reverted: [] };
3176
+ if (turn !== void 0 && tree !== void 0) {
3177
+ const preUndoTree = await trackTree(this.opts.cwd);
3178
+ if (preUndoTree !== null && await restoreTree(this.opts.cwd, tree)) {
3179
+ this.redoStack.push({ tree: preUndoTree, label: this.lastUndoneLabel });
3180
+ const dropped = await dropTurnSnapshots(turn, true);
3181
+ return {
3182
+ removed,
3183
+ reverted: [
3184
+ `Restored the working tree from the git snapshot taken at the turn's start (${tree.slice(0, 8)}; covers changes from any tool, bash included; ${dropped} per-file snapshot(s) discarded).`
3185
+ ],
3186
+ tree
3187
+ };
3188
+ }
3189
+ }
3190
+ const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
3191
+ return { removed, reverted };
3192
+ }
3193
+ // The redo entry /redo would restore, without popping it (for the confirm
3194
+ // prompt); null when there is nothing to redo.
3195
+ peekRedo() {
3196
+ return this.redoStack[this.redoStack.length - 1] ?? null;
3197
+ }
3198
+ // Restores the working tree to its state just before the last git-path
3199
+ // /undo. Conversation messages are not re-added — redo is file-level only.
3200
+ // The entry stays on the stack when the restore fails, so it can be retried.
3201
+ async redoLastUndo() {
3202
+ const entry = this.redoStack[this.redoStack.length - 1];
3203
+ if (!entry) return null;
3204
+ const names = await diffTreeNames(this.opts.cwd, entry.tree);
3205
+ if (!await restoreTree(this.opts.cwd, entry.tree)) {
3206
+ return { ok: false, tree: entry.tree, label: entry.label, files: null };
3207
+ }
3208
+ this.redoStack.pop();
3209
+ return { ok: true, tree: entry.tree, label: entry.label, files: names?.length ?? null };
2613
3210
  }
2614
3211
  // Cut point for a rewind: messages[index] should be the user message that
2615
3212
  // started the rewound turn, but compaction may have shifted indices, so
@@ -2655,13 +3252,18 @@ var AgentLoop = class {
2655
3252
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2656
3253
  return removed;
2657
3254
  }
2658
- // After the first assistant reply, kick off background title generation
2659
- // for the session. Runs once per loop; the store-level title check makes
2660
- // resumed sessions that already have a title a no-op.
2661
- maybeScheduleTitle(input) {
3255
+ // Background title generation runs once per loop, fed by the first real
3256
+ // user message of the session. It must NOT latch on the first assistant
3257
+ // step's text: that text is empty for a tool-call-only first step (the
3258
+ // common agentic case), which used to burn the one-shot flag on an empty
3259
+ // input and left every such session untitled. The store-level title check
3260
+ // makes resumed sessions that already have a title a no-op.
3261
+ maybeScheduleTitle() {
2662
3262
  if (this.titleScheduled) return;
2663
3263
  const store = this.opts.sessionStore;
2664
3264
  if (!store) return;
3265
+ const input = this.titleInput;
3266
+ if (!input) return;
2665
3267
  this.titleScheduled = true;
2666
3268
  scheduleSessionTitle(store, input, this.opts.model);
2667
3269
  }
@@ -2710,6 +3312,9 @@ var AgentLoop = class {
2710
3312
  this.syncSystemMessage();
2711
3313
  const inputText = typeof input === "string" ? input : input.text;
2712
3314
  const images = typeof input === "string" ? [] : input.images;
3315
+ if (this.titleInput === void 0 && inputText.trim().length > 0) {
3316
+ this.titleInput = inputText;
3317
+ }
2713
3318
  const userMessage = images.length > 0 ? {
2714
3319
  role: "user",
2715
3320
  content: [
@@ -2721,12 +3326,17 @@ var AgentLoop = class {
2721
3326
  { type: "text", text: inputText }
2722
3327
  ]
2723
3328
  } : { role: "user", content: inputText };
3329
+ const isRoot = (this.opts.subagentDepth ?? 0) === 0;
3330
+ const tree = isRoot && this.opts.config.gitSnapshots ? await trackTree(this.opts.cwd) ?? void 0 : void 0;
2724
3331
  this.messages.push(userMessage);
2725
- const seq = (this.opts.subagentDepth ?? 0) === 0 ? beginTurn(this.messages.length - 1) : currentTurnSeq();
2726
- this.turnMarkers.push({ seq, userIndex: this.messages.length - 1 });
3332
+ const seq = isRoot ? beginTurn(this.messages.length - 1) : currentTurnSeq();
3333
+ this.turnMarkers.push({ seq, userIndex: this.messages.length - 1, tree });
3334
+ this.activeTurnSeq = seq;
3335
+ this.activeTurnUserIndex = this.messages.length - 1;
2727
3336
  await this.persist(
2728
3337
  opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
2729
3338
  );
3339
+ this.maybeScheduleTitle();
2730
3340
  const { config, registry, cwd } = this.opts;
2731
3341
  const aiTools = this.buildAiTools();
2732
3342
  let autoContinues = 0;
@@ -2749,7 +3359,8 @@ ${finished.result}`
2749
3359
  const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2750
3360
  const compacted = compactMessages([...this.messages], maxTokens);
2751
3361
  if (compacted.compacted) {
2752
- this.messages = await this.applyCompactionSummary(compacted);
3362
+ this.messages = await this.applyCompactionSummary(compacted, signal);
3363
+ this.turnMarkers = [];
2753
3364
  }
2754
3365
  const maxRetries = Math.max(0, config.streamMaxRetries);
2755
3366
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
@@ -2757,12 +3368,16 @@ ${finished.result}`
2757
3368
  const toolCalls = [];
2758
3369
  let failure = null;
2759
3370
  let lastFinishReason;
3371
+ let lastUsage;
3372
+ let sawReasoning = false;
2760
3373
  let oversizedImagesStripped = false;
2761
3374
  for (let attempt = 0; ; attempt++) {
2762
3375
  text = "";
2763
3376
  toolCalls.length = 0;
2764
3377
  failure = null;
2765
3378
  lastFinishReason = void 0;
3379
+ lastUsage = void 0;
3380
+ sawReasoning = false;
2766
3381
  const timeoutScale = Math.min(attempt + 1, 3);
2767
3382
  try {
2768
3383
  for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
@@ -2770,12 +3385,14 @@ ${finished.result}`
2770
3385
  text += event.text;
2771
3386
  yield event;
2772
3387
  } else if (event.type === "reasoning") {
3388
+ sawReasoning = true;
2773
3389
  yield event;
2774
3390
  } else if (event.type === "tool-call") {
2775
3391
  toolCalls.push({ id: event.id, name: event.name, args: event.args });
2776
3392
  yield event;
2777
3393
  } else if (event.type === "finish") {
2778
3394
  lastFinishReason = event.finishReason;
3395
+ lastUsage = event.usage;
2779
3396
  yield event;
2780
3397
  } else if (event.type === "error") {
2781
3398
  failure = event.error;
@@ -2806,15 +3423,20 @@ ${finished.result}`
2806
3423
  }
2807
3424
  if (failure && !isRetryableStreamError(failure)) break;
2808
3425
  if (attempt >= maxRetries) break;
2809
- if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && isSteerableEmptyFinish(lastFinishReason))
3426
+ if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
2810
3427
  break;
3428
+ const delayMs = computeRetryDelayMs(attempt, baseDelay, failure);
2811
3429
  yield {
2812
3430
  type: "retry",
2813
3431
  attempt: attempt + 2,
2814
3432
  maxAttempts: maxRetries + 1,
2815
- reason: failure ? failure.message : `empty response (finish: ${lastFinishReason ?? "none"})`
3433
+ delayMs,
3434
+ reason: failure ? summarizeStreamError(failure) : `empty response (finish: ${lastFinishReason ?? "none"})`
2816
3435
  };
2817
- if (!await sleep(baseDelay * 2 ** attempt, signal)) return;
3436
+ if (!await sleep(delayMs, signal)) {
3437
+ yield* this.persistInterrupted(text, toolCalls);
3438
+ return;
3439
+ }
2818
3440
  }
2819
3441
  if (failure) {
2820
3442
  yield { type: "error", error: failure };
@@ -2856,7 +3478,6 @@ ${finished.result}`
2856
3478
  };
2857
3479
  this.messages.push(assistantMessage);
2858
3480
  await this.persist(assistantMessage);
2859
- this.maybeScheduleTitle(text);
2860
3481
  if (toolCalls.length === 0) {
2861
3482
  let reason = null;
2862
3483
  let nudgeText = AUTO_CONTINUE_NUDGE;
@@ -2904,7 +3525,11 @@ ${finished.result}`
2904
3525
  try {
2905
3526
  for (const call of toolCalls) {
2906
3527
  if (signal.aborted) break;
2907
- const result = await this.executeTool(call, signal);
3528
+ const refusal = this.doomLoopRefusal(call);
3529
+ if (refusal !== null) {
3530
+ yield { type: "notice", message: refusal };
3531
+ }
3532
+ const result = refusal !== null ? { content: refusal, isError: true } : await this.executeTool(call, signal);
2908
3533
  const toolMessage = {
2909
3534
  role: "tool",
2910
3535
  content: [
@@ -2981,7 +3606,7 @@ ${finished.result}`
2981
3606
  if (userMemory) parts.push(userMemory);
2982
3607
  const skills = discoverSkills(this.opts.cwd);
2983
3608
  if (skills.length > 0) parts.push(formatSkillsBlock(skills));
2984
- const git = getGitSummary(this.opts.cwd);
3609
+ const git = getGitSummaryCached(this.opts.cwd);
2985
3610
  if (git) parts.push(formatGitSummary(git));
2986
3611
  if (parts.length === 0) return;
2987
3612
  const content = parts.join("\n\n");
@@ -2992,7 +3617,7 @@ ${finished.result}`
2992
3617
  this.messages.unshift({ role: "system", content });
2993
3618
  }
2994
3619
  }
2995
- async applyCompactionSummary(compacted) {
3620
+ async applyCompactionSummary(compacted, signal) {
2996
3621
  const { config, model } = this.opts;
2997
3622
  if (config.contextCompaction !== "summary" || !model) {
2998
3623
  return compacted.messages;
@@ -3000,7 +3625,7 @@ ${finished.result}`
3000
3625
  const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
3001
3626
  const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
3002
3627
  try {
3003
- const summary = await summarizeMessages(dropped, model);
3628
+ const summary = await summarizeMessages(dropped, model, signal);
3004
3629
  const messages = compacted.messages.slice();
3005
3630
  messages[headCount] = {
3006
3631
  role: "user",
@@ -3013,15 +3638,23 @@ ${summary}`
3013
3638
  }
3014
3639
  }
3015
3640
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
3016
- const { streamChat } = await import("./stream-YQCRUBO3.js");
3017
- yield* streamChat({
3641
+ const { streamChat } = await import("./stream-H4BYBVAZ.js");
3642
+ for await (const event of streamChat({
3018
3643
  model: this.opts.model,
3019
3644
  messages: this.messages,
3020
3645
  tools: aiTools,
3021
3646
  abortSignal: signal,
3022
3647
  idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
3023
3648
  firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
3024
- });
3649
+ })) {
3650
+ if (event.type === "finish" && event.truncated) {
3651
+ yield {
3652
+ type: "notice",
3653
+ message: "Response was cut short by the stream idle timeout \u2014 the reply may be incomplete."
3654
+ };
3655
+ }
3656
+ yield event;
3657
+ }
3025
3658
  }
3026
3659
  buildAiTools() {
3027
3660
  const plan = this.opts.config.permissionMode === "plan";
@@ -3051,6 +3684,23 @@ ${summary}`
3051
3684
  return empty;
3052
3685
  }
3053
3686
  }
3687
+ // opencode's doom_loop guard: returns a refusal once the identical tool
3688
+ // call (name + stable-serialized args) has repeated config.doomLoopThreshold
3689
+ // times in a row; 0 disables. Any different signature resets the streak.
3690
+ // Subagents are separate loop instances, so each counts its own streak.
3691
+ doomLoopRefusal(call) {
3692
+ const threshold = this.opts.config.doomLoopThreshold;
3693
+ if (threshold <= 0) return null;
3694
+ const signature = `${call.name} ${stableStringify(call.args)}`;
3695
+ if (signature === this.lastToolSignature) {
3696
+ this.repeatedToolCalls += 1;
3697
+ } else {
3698
+ this.lastToolSignature = signature;
3699
+ this.repeatedToolCalls = 1;
3700
+ }
3701
+ if (this.repeatedToolCalls < threshold) return null;
3702
+ return `Refused: tool "${call.name}" was called ${this.repeatedToolCalls} times in a row with identical arguments. Stop retrying it \u2014 change your approach or report that you are blocked.`;
3703
+ }
3054
3704
  async executeTool(call, signal) {
3055
3705
  const { registry, config, cwd } = this.opts;
3056
3706
  const tool = registry.get(call.name);
@@ -3095,7 +3745,15 @@ ${summary}`
3095
3745
  }
3096
3746
  let result;
3097
3747
  try {
3098
- result = await tool.execute(parsed.data, { cwd, abortSignal: signal });
3748
+ result = await tool.execute(parsed.data, {
3749
+ cwd,
3750
+ abortSignal: signal,
3751
+ snapshotContext: {
3752
+ owner: (this.opts.subagentDepth ?? 0) > 0 ? "subagent" : "root",
3753
+ turn: this.activeTurnSeq,
3754
+ messageIndex: this.activeTurnUserIndex
3755
+ }
3756
+ });
3099
3757
  } catch (error) {
3100
3758
  if (signal.aborted) {
3101
3759
  return { content: "Tool execution aborted.", isError: true };
@@ -3126,40 +3784,34 @@ export {
3126
3784
  MAX_IMAGE_DIMENSION,
3127
3785
  probeImageDimensions,
3128
3786
  reconcileToolCalls,
3787
+ isSensitivePath,
3129
3788
  isAllowedByRules,
3130
3789
  buildAllowRule,
3131
3790
  isDangerousCommand,
3791
+ diffTreeNames,
3132
3792
  listSnapshots,
3133
3793
  clearSnapshots,
3134
3794
  hydrateSnapshots,
3135
3795
  listTurnSnapshots,
3136
3796
  resolveSnapshotContent,
3137
- undoTurnSnapshots,
3138
3797
  rewindToSnapshot,
3139
- TodoStore,
3140
- formatTodos,
3141
- parseTodoArgs,
3142
- loadTodos,
3143
- resetTodos,
3144
- starHome,
3145
- globalConfigPath,
3146
- sessionsDir,
3147
- userMemoryPath,
3148
- envFilePath,
3149
- projectConfigPath,
3150
- discoverSkills,
3151
3798
  defaultTaskManager,
3152
3799
  resolveShell,
3153
3800
  bashTool,
3154
3801
  SKIP_DIRS,
3155
3802
  createIgnorePredicate,
3156
- isSensitivePath2 as isSensitivePath,
3157
3803
  formatTaskList,
3158
3804
  formatTaskStarted,
3159
3805
  formatTaskFinished,
3806
+ TodoStore,
3807
+ formatTodos,
3808
+ parseTodoArgs,
3809
+ loadTodos,
3810
+ resetTodos,
3811
+ discoverSkills,
3160
3812
  createDefaultRegistry,
3161
3813
  appendUserMemory,
3162
3814
  PLAN_MODE_PROMPT,
3163
3815
  AgentLoop
3164
3816
  };
3165
- //# sourceMappingURL=chunk-K4SIXWCI.js.map
3817
+ //# sourceMappingURL=chunk-3L6QKJM4.js.map