@cryer/star-cli 0.3.2 → 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,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  computeRetryDelayMs,
4
+ gitTreesDir,
4
5
  isRetryableStreamError,
6
+ responseBodyOf,
5
7
  starHome,
6
8
  summarizeStreamError,
7
9
  userMemoryPath
8
- } from "./chunk-LADQCYKL.js";
10
+ } from "./chunk-B6P4VUBE.js";
9
11
 
10
12
  // src/agent/loop.ts
11
13
  import { tool as aiTool, generateText as generateText3 } from "ai";
@@ -89,14 +91,18 @@ function serializeMessage(message) {
89
91
  });
90
92
  return `${message.role}: ${parts.join("\n")}`;
91
93
  }
92
- async function summarizeMessages(messages, model) {
94
+ async function summarizeMessages(messages, model, signal) {
93
95
  const transcript = messages.map(serializeMessage).join("\n");
94
96
  const { text } = await generateText({
95
97
  model,
96
98
  system: SUMMARY_SYSTEM_PROMPT,
97
99
  prompt: `Summarize this conversation so far:
98
100
 
99
- ${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)
100
106
  });
101
107
  return text.trim();
102
108
  }
@@ -172,6 +178,16 @@ function getGitSummary(cwd) {
172
178
  const log = runGit(["log", "--oneline", "-3"], cwd) ?? "";
173
179
  return { branch, dirtyCount, recentCommits: log === "" ? [] : log.split("\n") };
174
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
+ }
175
191
  function formatGitSummary(summary) {
176
192
  const lines = [
177
193
  "Git context for the working directory (refreshed each turn):",
@@ -332,7 +348,8 @@ var IMAGE_SIZE_KEYWORDS = /too[\s-]*(large|big)|exceeds?|dimensions?|resolution|
332
348
  function isOversizedImageError(error) {
333
349
  const status = error.statusCode;
334
350
  if (typeof status !== "number" || status < 400 || status >= 500) return false;
335
- 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);
336
353
  }
337
354
  function imagePartBytes(image) {
338
355
  if (typeof image === "string") {
@@ -541,8 +558,50 @@ async function runHooks(event, hooks2, ctx) {
541
558
  }
542
559
 
543
560
  // src/permissions/gate.ts
561
+ import fs from "fs";
544
562
  import path from "path";
545
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
+
546
605
  // src/permissions/allow.ts
547
606
  function parseAllowRule(rule) {
548
607
  const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
@@ -566,11 +625,32 @@ function matchAllowRule(rule, req) {
566
625
  const target = requestTarget(req);
567
626
  return target !== void 0 && globMatch(rule.pattern, target);
568
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
+ }
569
638
  function isAllowedByRules(rules, req) {
570
- 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)));
571
646
  }
572
647
  function isDeniedByRules(rules, req) {
573
- 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)));
574
654
  }
575
655
  function matchesAnyRule(rules, req) {
576
656
  return rules.some((raw) => {
@@ -584,10 +664,17 @@ function buildAllowRule(req) {
584
664
  }
585
665
 
586
666
  // src/permissions/gate.ts
587
- 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"]);
588
668
  var DANGEROUS_COMMAND_PATTERNS = [
589
669
  /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
590
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,
591
678
  /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
592
679
  /\bmkfs\b/i,
593
680
  /dd\s+[^|;]*\bif=/i,
@@ -617,12 +704,30 @@ function isOutsideCwd(p, cwd) {
617
704
  const base = normalizeAbsolute(cwd, cwd);
618
705
  return abs !== base && !abs.startsWith(`${base}/`);
619
706
  }
620
- function isSensitivePath(p) {
621
- const base = p.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
622
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
623
- 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);
624
729
  }
625
- return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
730
+ return isOutsideCwd(resolved, realCwd);
626
731
  }
627
732
  function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
628
733
  if (mode === "yolo") {
@@ -634,10 +739,12 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
634
739
  return "deny";
635
740
  }
636
741
  if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
637
- 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)) {
638
744
  return "deny";
639
745
  }
640
- if (isOutsideCwd(filePath, ctx.cwd)) {
746
+ const outside = isWriteTool ? isOutsideForWrite(filePath, ctx.cwd) : isOutsideCwd(filePath, ctx.cwd);
747
+ if (outside) {
641
748
  if (mode === "ask" && req.level === "read") {
642
749
  return "ask";
643
750
  }
@@ -708,10 +815,93 @@ function scheduleSessionTitle(store, userText, model) {
708
815
  });
709
816
  }
710
817
 
711
- // src/tools/fs/snapshots.ts
712
- 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";
713
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";
714
903
  var MAX_SNAPSHOTS = 50;
904
+ var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
715
905
  var stack = [];
716
906
  var activeTurn = 0;
717
907
  var activeTurnMessageIndex = -1;
@@ -733,28 +923,48 @@ function currentTurnSeq() {
733
923
  function currentTurnMessageIndex() {
734
924
  return activeTurnMessageIndex;
735
925
  }
736
- async function captureSnapshot(filePath, toolName) {
926
+ function ownerOf(snapshot) {
927
+ return snapshot.owner ?? "root";
928
+ }
929
+ async function captureSnapshot(filePath, toolName, snapshotContext) {
737
930
  let content = null;
738
931
  let existed = false;
739
- try {
740
- content = await readFile(filePath, "utf8");
932
+ let contentTooLarge = false;
933
+ const st = await stat(filePath).catch(() => null);
934
+ if (st?.isFile()) {
741
935
  existed = true;
742
- } 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
+ }
743
945
  }
744
946
  return {
745
947
  id: nextSnapshotId(),
746
948
  path: filePath,
747
949
  existed,
748
950
  content,
951
+ contentTooLarge: contentTooLarge || void 0,
749
952
  toolName,
750
953
  timestamp: Date.now(),
751
- turn: activeTurn,
752
- messageIndex: activeTurnMessageIndex
954
+ turn: snapshotContext?.turn ?? activeTurn,
955
+ messageIndex: snapshotContext?.messageIndex ?? activeTurnMessageIndex,
956
+ owner: snapshotContext?.owner ?? "root"
753
957
  };
754
958
  }
755
959
  async function pushSnapshot(snapshot) {
756
960
  stack.push(snapshot);
757
- 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
+ }
758
968
  if (stack.length > MAX_SNAPSHOTS) {
759
969
  const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
760
970
  await removeFromStore(evicted.map((s) => s.id));
@@ -787,191 +997,156 @@ async function resolveContent(snapshot) {
787
997
  }
788
998
  async function revert(snapshot) {
789
999
  if (snapshot.existed) {
1000
+ if (snapshot.contentTooLarge) {
1001
+ return `Skipped ${snapshot.path}: content too large, not restored.`;
1002
+ }
790
1003
  const content = await resolveContent(snapshot);
791
1004
  if (content === null) {
792
1005
  return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
793
1006
  }
794
- await mkdir(path2.dirname(snapshot.path), { recursive: true });
1007
+ await mkdir2(path3.dirname(snapshot.path), { recursive: true });
795
1008
  await writeFile(snapshot.path, content, "utf8");
796
1009
  return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
797
1010
  }
798
1011
  await rm(snapshot.path, { force: true });
799
1012
  return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
800
1013
  }
801
- function listTurnSnapshots(turn) {
802
- 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();
803
1018
  }
804
1019
  function resolveSnapshotContent(snapshot) {
805
1020
  return resolveContent(snapshot);
806
1021
  }
807
- async function undoTurnSnapshots(turn) {
1022
+ async function undoTurnSnapshots(turn, includeSubagent = false) {
808
1023
  const matches = [];
809
1024
  for (let i = stack.length - 1; i >= 0; i--) {
810
1025
  const snapshot = stack[i];
811
- if (snapshot && snapshot.turn === turn) {
1026
+ if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
812
1027
  matches.push(snapshot);
813
- stack.splice(i, 1);
814
1028
  }
815
1029
  }
816
- await removeFromStore(matches.map((s) => s.id));
817
1030
  const messages = [];
1031
+ const revertedIds = [];
818
1032
  for (const snapshot of matches) {
819
- 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
+ }
820
1043
  }
1044
+ await removeFromStore(revertedIds);
821
1045
  return messages;
822
1046
  }
823
- 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) {
824
1060
  if (!stack.some((snapshot) => snapshot.id === id)) return null;
825
1061
  const matches = [];
826
1062
  for (let i = stack.length - 1; i >= 0; i--) {
827
1063
  const snapshot = stack[i];
828
- if (snapshot && snapshot.id >= id) {
1064
+ if (snapshot && snapshot.id >= id && (includeSubagent || ownerOf(snapshot) === "root")) {
829
1065
  matches.push(snapshot);
830
- stack.splice(i, 1);
831
1066
  }
832
1067
  }
833
- await removeFromStore(matches.map((s) => s.id));
834
1068
  const reverted = [];
1069
+ const revertedIds = [];
835
1070
  let messageIndex = null;
836
1071
  for (const snapshot of matches) {
837
- reverted.push(await revert(snapshot));
838
- if (snapshot.messageIndex >= 0) {
839
- 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
+ );
840
1084
  }
841
1085
  }
1086
+ await removeFromStore(revertedIds);
842
1087
  return { reverted, messageIndex };
843
1088
  }
844
1089
 
845
- // src/tools/todo.ts
846
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
847
- 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";
848
1094
  import { z } from "zod";
849
- var todoItemSchema = z.object({
850
- id: z.number().int(),
851
- title: z.string(),
852
- status: z.enum(["pending", "in_progress", "done"])
853
- });
854
- var writeSchema = z.object({
855
- todos: z.array(todoItemSchema).describe("The full todo list, replacing the current one")
856
- });
857
- var readSchema = z.object({});
858
- function fileFor(cwd) {
859
- return path3.join(cwd, ".star", "todos.json");
860
- }
861
- var TodoStore = class {
862
- items = /* @__PURE__ */ new Map();
863
- list() {
864
- return [...this.items.values()];
865
- }
866
- replace(items) {
867
- this.items.clear();
868
- for (const item of items) {
869
- this.items.set(item.id, item);
870
- }
871
- }
872
- async load(cwd) {
873
- let raw;
874
- try {
875
- raw = await readFile2(fileFor(cwd), "utf8");
876
- } catch {
877
- return;
878
- }
879
- try {
880
- const parsed = JSON.parse(raw);
881
- if (Array.isArray(parsed)) {
882
- this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
883
- }
884
- } catch {
885
- }
886
- }
887
- async save(cwd) {
888
- await mkdir2(path3.join(cwd, ".star"), { recursive: true });
889
- await writeFile2(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
890
- `);
891
- }
892
- };
893
- var SYMBOLS = {
894
- pending: "[ ]",
895
- in_progress: "[~]",
896
- done: "[x]"
897
- };
898
- function formatTodos(items) {
899
- if (items.length === 0) {
900
- return "No todos.";
901
- }
902
- const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
903
- const width = Math.max(...lines.map((l) => l.length));
904
- return lines.map((line, i) => {
905
- const status = items[i]?.status;
906
- return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
907
- }).join("\n");
908
- }
909
- var defaultStore = new TodoStore();
910
- function parseTodoArgs(args) {
911
- const parsed = writeSchema.safeParse(args);
912
- return parsed.success ? parsed.data.todos : null;
913
- }
914
- function pendingTodoTitles(args) {
915
- return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
916
- }
917
- async function loadTodos(cwd) {
918
- await defaultStore.load(cwd);
919
- return defaultStore.list();
920
- }
921
- function resetTodos() {
922
- defaultStore.replace([]);
923
- }
924
- function createTodoTools(store = defaultStore) {
925
- const todoWrite = {
926
- name: "todo_write",
927
- description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
928
- permission: "read",
929
- parameters: writeSchema,
930
- async execute(args, ctx) {
931
- const parsed = writeSchema.safeParse(args);
932
- if (!parsed.success) {
933
- return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
934
- }
935
- store.replace(parsed.data.todos);
936
- await store.save(ctx.cwd);
937
- const items = store.list();
938
- const done = items.filter((it) => it.status === "done").length;
939
- return { content: `Todo list updated (${done}/${items.length} done).` };
940
- }
941
- };
942
- const todoRead = {
943
- name: "todo_read",
944
- description: "Read the current todo list.",
945
- permission: "read",
946
- parameters: readSchema,
947
- async execute() {
948
- return { content: formatTodos(store.list()) };
949
- }
950
- };
951
- return [todoWrite, todoRead];
952
- }
953
1095
 
954
- // src/agent/agent-tasks.ts
1096
+ // src/tasks/manager.ts
1097
+ import { spawn as spawn2 } from "child_process";
955
1098
  import { EventEmitter } from "events";
956
- 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 {
957
1104
  records = /* @__PURE__ */ new Map();
958
- counter = 0;
959
- start(run, meta) {
960
- 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
+ });
961
1116
  const rec = {
962
1117
  id,
963
- prompt: meta.prompt,
964
- description: meta.description,
1118
+ command: opts.command,
1119
+ description: opts.description,
965
1120
  status: "running",
966
1121
  startedAt: Date.now(),
967
- result: "",
968
- controller: new AbortController(),
969
- notified: false
1122
+ output: "",
1123
+ truncated: false,
1124
+ child
970
1125
  };
971
1126
  this.records.set(id, rec);
972
- void run(rec.controller.signal).then((result) => this.finish(rec, "completed", result)).catch(
973
- (error) => this.finish(rec, "failed", error instanceof Error ? error.message : String(error))
974
- );
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();
975
1150
  this.emitUpdate(rec);
976
1151
  return this.snapshot(rec);
977
1152
  }
@@ -992,347 +1167,75 @@ var AgentTaskManager = class extends EventEmitter {
992
1167
  kill(id) {
993
1168
  const rec = this.records.get(id);
994
1169
  if (!rec || rec.status !== "running") return void 0;
995
- rec.controller.abort();
996
- this.finish(rec, "stopped", rec.result || "Stopped by user.");
1170
+ this.terminate(rec, "stopped");
997
1171
  return this.snapshot(rec);
998
1172
  }
999
1173
  cleanup() {
1000
1174
  const killed = [];
1001
1175
  for (const rec of this.records.values()) {
1002
1176
  if (rec.status === "running") {
1003
- rec.controller.abort();
1004
- this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
1177
+ this.terminate(rec, "stopped");
1005
1178
  killed.push(this.snapshot(rec));
1006
1179
  }
1007
1180
  }
1008
1181
  return killed;
1009
1182
  }
1010
- // Finished tasks whose result has not yet been handed to the parent loop.
1011
- drainNotifications() {
1012
- const drained = [];
1013
- for (const rec of this.records.values()) {
1014
- if (rec.status !== "running" && !rec.notified) {
1015
- rec.notified = true;
1016
- drained.push(this.snapshot(rec));
1017
- }
1018
- }
1019
- return drained;
1183
+ terminate(rec, status) {
1184
+ killTree2(rec.child);
1185
+ this.finish(rec, status, null);
1020
1186
  }
1021
- finish(rec, status, result) {
1022
- if (rec.status !== "running") return;
1187
+ finish(rec, status, exitCode) {
1188
+ if (rec.timer) clearTimeout(rec.timer);
1023
1189
  rec.status = status;
1024
- rec.result = result;
1190
+ rec.exitCode = exitCode;
1025
1191
  rec.endedAt = Date.now();
1192
+ this.pruneFinished();
1026
1193
  this.emitUpdate(rec);
1027
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
+ }
1028
1203
  emitUpdate(rec) {
1029
1204
  this.emit("update", this.snapshot(rec));
1030
1205
  }
1031
1206
  snapshot(rec) {
1032
1207
  return {
1033
1208
  id: rec.id,
1034
- prompt: rec.prompt,
1209
+ command: rec.command,
1035
1210
  description: rec.description,
1036
1211
  status: rec.status,
1037
1212
  startedAt: rec.startedAt,
1038
1213
  endedAt: rec.endedAt,
1039
- result: rec.result
1214
+ exitCode: rec.exitCode,
1215
+ output: rec.truncated ? TRUNCATED_PREFIX + rec.output : rec.output
1040
1216
  };
1041
1217
  }
1042
1218
  };
1043
- var defaultAgentTasks = new AgentTaskManager();
1044
-
1045
- // src/agent/project-memory.ts
1046
- import { readFileSync, statSync } from "fs";
1047
- import path4 from "path";
1048
- var PROJECT_MEMORY_FILE = "AGENTS.md";
1049
- var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
1050
- var cache = /* @__PURE__ */ new Map();
1051
- function readProjectMemory(cwd) {
1052
- const file = path4.join(cwd, PROJECT_MEMORY_FILE);
1053
- let mtimeMs;
1054
- try {
1055
- mtimeMs = statSync(file).mtimeMs;
1056
- } catch {
1057
- return null;
1058
- }
1059
- const hit = cache.get(file);
1060
- if (hit && hit.mtimeMs === mtimeMs) return hit.block;
1061
- let block = null;
1062
- try {
1063
- let body = readFileSync(file, "utf8").trim();
1064
- if (body) {
1065
- if (body.length > PROJECT_MEMORY_MAX_CHARS) {
1066
- body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
1067
-
1068
- [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
1069
- }
1070
- block = `# Project instructions (AGENTS.md)
1071
- ${body}`;
1072
- }
1073
- } catch {
1074
- block = null;
1075
- }
1076
- cache.set(file, { mtimeMs, block });
1077
- return block;
1078
- }
1079
-
1080
- // src/agent/skills.ts
1081
- import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
1082
- import path5 from "path";
1083
- import { z as z2 } from "zod";
1084
- var SKILL_FILE = "SKILL.md";
1085
- var SKILL_MAX_CHARS = 32 * 1024;
1086
- var NAME_PATTERN = /^[a-z0-9-]+$/;
1087
- function parseFrontmatter(raw) {
1088
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
1089
- if (!match) return null;
1090
- const meta = {};
1091
- for (const line of (match[1] ?? "").split(/\r?\n/)) {
1092
- const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
1093
- if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
1094
- }
1095
- return { meta, body: raw.slice(match[0].length).trim() };
1096
- }
1097
- var cache2 = /* @__PURE__ */ new Map();
1098
- function parseSkillFile(filePath, dirName, scope) {
1099
- let mtimeMs;
1100
- try {
1101
- mtimeMs = statSync2(filePath).mtimeMs;
1102
- } catch {
1103
- return null;
1104
- }
1105
- const hit = cache2.get(filePath);
1106
- if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
1107
- let skill = null;
1108
- try {
1109
- const parsed = parseFrontmatter(readFileSync2(filePath, "utf8"));
1110
- const name = parsed?.meta.name || dirName;
1111
- const description = parsed?.meta.description;
1112
- if (parsed && NAME_PATTERN.test(name) && description) {
1113
- skill = { name, description, filePath, dir: path5.dirname(filePath), scope };
1114
- }
1115
- } catch {
1116
- skill = null;
1117
- }
1118
- cache2.set(filePath, { mtimeMs, skill });
1119
- return skill;
1120
- }
1121
- function scanDir(base, scope, into) {
1122
- let entries;
1123
- try {
1124
- entries = readdirSync(base);
1125
- } catch {
1126
- return;
1127
- }
1128
- for (const entry of entries.sort()) {
1129
- if (!NAME_PATTERN.test(entry)) continue;
1130
- const parsed = parseSkillFile(path5.join(base, entry, SKILL_FILE), entry, scope);
1131
- if (parsed) into.set(parsed.name, parsed);
1132
- }
1133
- }
1134
- function discoverSkills(cwd, home = starHome()) {
1135
- const byName = /* @__PURE__ */ new Map();
1136
- scanDir(path5.join(home, "skills"), "user", byName);
1137
- scanDir(path5.join(cwd, ".star", "skills"), "project", byName);
1138
- return [...byName.values()];
1139
- }
1140
- function formatSkillsBlock(skills) {
1141
- const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
1142
- return [
1143
- "# Available skills",
1144
- "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
1145
- ...lines
1146
- ].join("\n");
1147
- }
1148
- function loadSkillBody(skill) {
1149
- let parsed = null;
1150
- try {
1151
- parsed = parseFrontmatter(readFileSync2(skill.filePath, "utf8"));
1152
- } catch {
1153
- return null;
1154
- }
1155
- if (!parsed?.body) return null;
1156
- if (parsed.body.length > SKILL_MAX_CHARS) {
1157
- return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
1158
-
1159
- [skill truncated to ${SKILL_MAX_CHARS} characters]`;
1160
- }
1161
- return parsed.body;
1162
- }
1163
- function createSkillTool(deps) {
1164
- return {
1165
- name: "skill",
1166
- 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.",
1167
- permission: "read",
1168
- parameters: z2.object({
1169
- name: z2.string().describe("Name of the skill to load, as listed in the system prompt.")
1170
- }),
1171
- async execute(args) {
1172
- const skills = discoverSkills(deps.cwd, deps.home);
1173
- const skill = skills.find((s) => s.name === args.name);
1174
- if (!skill) {
1175
- const available = skills.map((s) => s.name).join(", ") || "none";
1176
- return {
1177
- content: `Unknown skill: "${args.name}". Available skills: ${available}`,
1178
- isError: true
1179
- };
1180
- }
1181
- const body = loadSkillBody(skill);
1182
- if (!body) {
1183
- return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
1184
- }
1185
- return {
1186
- content: [
1187
- `# Skill: ${skill.name}`,
1188
- "",
1189
- body,
1190
- "",
1191
- `Base directory for relative file references in this skill: ${skill.dir}`
1192
- ].join("\n")
1193
- };
1194
- }
1195
- };
1196
- }
1197
-
1198
- // src/agent/subagent.ts
1199
- import { z as z12 } from "zod";
1200
-
1201
- // src/tools/bash.ts
1202
- import { spawn as spawn3 } from "child_process";
1203
- import { existsSync } from "fs";
1204
- import path6 from "path";
1205
- import { z as z3 } from "zod";
1206
-
1207
- // src/tasks/manager.ts
1208
- import { spawn as spawn2 } from "child_process";
1209
- import { EventEmitter as EventEmitter2 } from "events";
1210
- var MAX_TASK_OUTPUT = 1e5;
1211
- var DEFAULT_TASK_TIMEOUT = 3600;
1212
- var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
1213
- var TaskManager = class extends EventEmitter2 {
1214
- records = /* @__PURE__ */ new Map();
1215
- seq = 0;
1216
- start(opts) {
1217
- const id = `task-${++this.seq}`;
1218
- const spec = resolveShell();
1219
- const child = spawn2(spec.shell, spec.wrap(opts.command), {
1220
- cwd: opts.cwd,
1221
- windowsHide: true
1222
- });
1223
- const rec = {
1224
- id,
1225
- command: opts.command,
1226
- description: opts.description,
1227
- status: "running",
1228
- startedAt: Date.now(),
1229
- output: "",
1230
- truncated: false,
1231
- child
1232
- };
1233
- this.records.set(id, rec);
1234
- const append = (d) => {
1235
- rec.output += d.toString("utf8");
1236
- if (rec.output.length > MAX_TASK_OUTPUT) {
1237
- rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
1238
- rec.truncated = true;
1239
- }
1240
- };
1241
- child.stdout.on("data", append);
1242
- child.stderr.on("data", append);
1243
- child.on("error", (err) => {
1244
- append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
1245
- `));
1246
- if (rec.status === "running") {
1247
- this.finish(rec, "failed", null);
1248
- }
1249
- });
1250
- child.on("close", (code) => {
1251
- if (rec.status !== "running") return;
1252
- this.finish(rec, code === 0 ? "completed" : "failed", code);
1253
- });
1254
- const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
1255
- rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
1256
- rec.timer.unref();
1257
- this.emitUpdate(rec);
1258
- return this.snapshot(rec);
1259
- }
1260
- get(id) {
1261
- const rec = this.records.get(id);
1262
- return rec ? this.snapshot(rec) : void 0;
1263
- }
1264
- list() {
1265
- return [...this.records.values()].map((rec) => this.snapshot(rec));
1266
- }
1267
- runningCount() {
1268
- let count = 0;
1269
- for (const rec of this.records.values()) {
1270
- if (rec.status === "running") count += 1;
1271
- }
1272
- return count;
1273
- }
1274
- kill(id) {
1275
- const rec = this.records.get(id);
1276
- if (!rec || rec.status !== "running") return void 0;
1277
- this.terminate(rec, "stopped");
1278
- return this.snapshot(rec);
1279
- }
1280
- cleanup() {
1281
- const killed = [];
1282
- for (const rec of this.records.values()) {
1283
- if (rec.status === "running") {
1284
- this.terminate(rec, "stopped");
1285
- killed.push(this.snapshot(rec));
1286
- }
1287
- }
1288
- return killed;
1289
- }
1290
- terminate(rec, status) {
1291
- killTree2(rec.child);
1292
- this.finish(rec, status, null);
1293
- }
1294
- finish(rec, status, exitCode) {
1295
- if (rec.timer) clearTimeout(rec.timer);
1296
- rec.status = status;
1297
- rec.exitCode = exitCode;
1298
- rec.endedAt = Date.now();
1299
- this.emitUpdate(rec);
1300
- }
1301
- emitUpdate(rec) {
1302
- this.emit("update", this.snapshot(rec));
1303
- }
1304
- snapshot(rec) {
1305
- return {
1306
- id: rec.id,
1307
- command: rec.command,
1308
- description: rec.description,
1309
- status: rec.status,
1310
- startedAt: rec.startedAt,
1311
- endedAt: rec.endedAt,
1312
- exitCode: rec.exitCode,
1313
- output: rec.truncated ? TRUNCATED_PREFIX + rec.output : rec.output
1314
- };
1315
- }
1316
- };
1317
- var defaultTaskManager = new TaskManager();
1219
+ var defaultTaskManager = new TaskManager();
1318
1220
 
1319
1221
  // src/tools/bash.ts
1320
1222
  var MAX_OUTPUT = 3e4;
1223
+ var HALF_OUTPUT = MAX_OUTPUT / 2;
1321
1224
  var DEFAULT_TIMEOUT = 120;
1322
1225
  var MAX_TIMEOUT = 600;
1323
1226
  function findOnPath(exe, exclude) {
1324
- for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
1227
+ for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
1325
1228
  if (!dir || exclude?.(dir)) {
1326
1229
  continue;
1327
1230
  }
1328
- const full = path6.join(dir, exe);
1329
- if (existsSync(full)) {
1231
+ const full = path4.join(dir, exe);
1232
+ if (existsSync2(full)) {
1330
1233
  return full;
1331
1234
  }
1332
1235
  }
1333
1236
  return null;
1334
1237
  }
1335
- function resolveShell() {
1238
+ function resolveShellUncached() {
1336
1239
  if (process.platform !== "win32") {
1337
1240
  return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
1338
1241
  }
@@ -1343,8 +1246,8 @@ function resolveShell() {
1343
1246
  }
1344
1247
  const gitExe = findOnPath("git.exe");
1345
1248
  if (gitExe) {
1346
- const sibling = path6.join(path6.dirname(path6.dirname(gitExe)), "bin", "bash.exe");
1347
- if (existsSync(sibling)) {
1249
+ const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
1250
+ if (existsSync2(sibling)) {
1348
1251
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1349
1252
  }
1350
1253
  }
@@ -1353,42 +1256,82 @@ function resolveShell() {
1353
1256
  "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1354
1257
  "D:\\Git\\bin\\bash.exe"
1355
1258
  ]) {
1356
- if (existsSync(candidate)) {
1259
+ if (existsSync2(candidate)) {
1357
1260
  return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1358
1261
  }
1359
1262
  }
1360
1263
  const comspec = process.env.ComSpec ?? "cmd.exe";
1361
1264
  return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
1362
1265
  }
1363
- var schema = z3.object({
1364
- command: z3.string().describe("Shell command to execute"),
1365
- timeout: z3.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1366
- description: z3.string().optional().describe("Short description of what the command does"),
1367
- 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(
1368
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."
1369
1279
  )
1370
1280
  });
1371
1281
  function killTree2(child) {
1372
- if (process.platform === "win32" && child.pid) {
1373
- spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1374
- windowsHide: true,
1375
- stdio: "ignore"
1376
- }).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
+ }
1377
1289
  return;
1378
1290
  }
1291
+ if (child.pid) {
1292
+ try {
1293
+ process.kill(-child.pid, "SIGKILL");
1294
+ return;
1295
+ } catch {
1296
+ }
1297
+ }
1379
1298
  child.kill("SIGKILL");
1380
1299
  }
1381
- function truncateMiddle(s) {
1382
- if (s.length <= MAX_OUTPUT) {
1383
- 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
+ }
1384
1325
  }
1385
- const half = Math.floor(MAX_OUTPUT / 2);
1386
- const head = s.slice(0, half);
1387
- const tail = s.slice(-half);
1388
- return `${head}
1389
- ... [${s.length - MAX_OUTPUT} characters truncated] ...
1390
- ${tail}`;
1391
- }
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
+ };
1392
1335
  var bashTool = {
1393
1336
  name: "bash",
1394
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.",
@@ -1413,9 +1356,10 @@ description: ${args.description ?? "(none)"}`
1413
1356
  return new Promise((resolve) => {
1414
1357
  const child = spawn3(spec.shell, spec.wrap(args.command), {
1415
1358
  cwd: ctx.cwd,
1416
- windowsHide: true
1359
+ windowsHide: true,
1360
+ detached: process.platform !== "win32"
1417
1361
  });
1418
- let output = "";
1362
+ const output = new BoundedOutput();
1419
1363
  let settled = false;
1420
1364
  const finish = (result) => {
1421
1365
  if (settled) {
@@ -1428,36 +1372,32 @@ description: ${args.description ?? "(none)"}`
1428
1372
  };
1429
1373
  const onAbort = () => {
1430
1374
  killTree2(child);
1431
- finish({ content: `${truncateMiddle(output)}
1375
+ finish({ content: `${output}
1432
1376
  Command aborted`, isError: true });
1433
1377
  };
1434
1378
  const timer = setTimeout(() => {
1435
1379
  killTree2(child);
1436
1380
  finish({
1437
- content: `${truncateMiddle(output)}
1381
+ content: `${output}
1438
1382
  Command timed out after ${timeoutSeconds}s`,
1439
1383
  isError: true
1440
1384
  });
1441
1385
  }, timeoutSeconds * 1e3);
1442
- child.stdout.on("data", (d) => {
1443
- output += d.toString("utf8");
1444
- });
1445
- child.stderr.on("data", (d) => {
1446
- output += d.toString("utf8");
1447
- });
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")));
1448
1389
  child.on("error", (err) => {
1449
1390
  finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
1450
1391
  });
1451
1392
  ctx.abortSignal?.addEventListener("abort", onAbort);
1452
1393
  child.on("close", (code) => {
1453
- const trimmed = output.replace(/\s+$/, "");
1394
+ const trimmed = output.toString().replace(/\s+$/, "");
1454
1395
  if (code === 0) {
1455
- finish({ content: truncateMiddle(trimmed) || "(no output)" });
1396
+ finish({ content: trimmed || "(no output)" });
1456
1397
  return;
1457
1398
  }
1458
- const body = truncateMiddle(trimmed);
1459
1399
  finish({
1460
- content: `${body}${body ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1400
+ content: `${trimmed}${trimmed ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1461
1401
  isError: true
1462
1402
  });
1463
1403
  });
@@ -1466,14 +1406,14 @@ Command timed out after ${timeoutSeconds}s`,
1466
1406
  };
1467
1407
 
1468
1408
  // src/tools/fs/edit.ts
1469
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1470
- import path7 from "path";
1471
- import { z as z4 } from "zod";
1472
- var schema2 = z4.object({
1473
- path: z4.string().describe("File path, absolute or relative to the working directory"),
1474
- old_string: z4.string().min(1).describe("Exact text to replace"),
1475
- new_string: z4.string().describe("Replacement text"),
1476
- 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")
1477
1417
  });
1478
1418
  var editFileTool = {
1479
1419
  name: "edit_file",
@@ -1481,10 +1421,12 @@ var editFileTool = {
1481
1421
  permission: "write",
1482
1422
  parameters: schema2,
1483
1423
  async execute(args, ctx) {
1484
- const filePath = path7.resolve(ctx.cwd, args.path);
1424
+ const filePath = path5.resolve(ctx.cwd, args.path);
1485
1425
  let content;
1426
+ let readMtimeMs;
1486
1427
  try {
1487
- content = await readFile3(filePath, "utf8");
1428
+ content = await readFile2(filePath, "utf8");
1429
+ readMtimeMs = (await stat2(filePath)).mtimeMs;
1488
1430
  } catch (err) {
1489
1431
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1490
1432
  }
@@ -1503,34 +1445,44 @@ var editFileTool = {
1503
1445
  isError: true
1504
1446
  };
1505
1447
  }
1506
- 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);
1507
1449
  try {
1508
- 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");
1509
1458
  } catch (err) {
1510
1459
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1511
1460
  }
1461
+ const contentTooLarge = Buffer.byteLength(content, "utf8") > MAX_SNAPSHOT_CONTENT_BYTES;
1512
1462
  await pushSnapshot({
1513
1463
  id: nextSnapshotId(),
1514
1464
  path: filePath,
1515
1465
  existed: true,
1516
- content,
1466
+ content: contentTooLarge ? null : content,
1467
+ contentTooLarge: contentTooLarge || void 0,
1517
1468
  toolName: "edit_file",
1518
1469
  timestamp: Date.now(),
1519
- turn: currentTurnSeq(),
1520
- messageIndex: currentTurnMessageIndex()
1470
+ turn: ctx.snapshotContext?.turn ?? currentTurnSeq(),
1471
+ messageIndex: ctx.snapshotContext?.messageIndex ?? currentTurnMessageIndex(),
1472
+ owner: ctx.snapshotContext?.owner ?? "root"
1521
1473
  });
1522
1474
  return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
1523
1475
  }
1524
1476
  };
1525
1477
 
1526
1478
  // src/tools/fs/glob.ts
1527
- import path9 from "path";
1528
- import { z as z5 } from "zod";
1479
+ import path7 from "path";
1480
+ import { z as z3 } from "zod";
1529
1481
 
1530
1482
  // src/tools/fs/util.ts
1531
- import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
1532
- import { readdir, stat } from "fs/promises";
1533
- import path8 from "path";
1483
+ import { readFileSync, statSync } from "fs";
1484
+ import { readdir, stat as stat3 } from "fs/promises";
1485
+ import path6 from "path";
1534
1486
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
1535
1487
  async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1536
1488
  const out = [];
@@ -1540,7 +1492,7 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1540
1492
  return;
1541
1493
  }
1542
1494
  for (const entry of entries) {
1543
- const abs = path8.join(dir, entry.name);
1495
+ const abs = path6.join(dir, entry.name);
1544
1496
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1545
1497
  if (entry.isDirectory()) {
1546
1498
  if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
@@ -1551,8 +1503,12 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1551
1503
  if (options.ignore?.(abs, false)) {
1552
1504
  continue;
1553
1505
  }
1506
+ if (options.withMtime === false) {
1507
+ out.push({ abs, rel, mtimeMs: 0 });
1508
+ continue;
1509
+ }
1554
1510
  try {
1555
- const st = await stat(abs);
1511
+ const st = await stat3(abs);
1556
1512
  out.push({ abs, rel, mtimeMs: st.mtimeMs });
1557
1513
  } catch {
1558
1514
  }
@@ -1563,32 +1519,107 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1563
1519
  return out;
1564
1520
  }
1565
1521
  var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
1522
+ function escapeRe(c) {
1523
+ return c.replace(REGEX_SPECIALS, "\\$&");
1524
+ }
1566
1525
  function globToRegExp(pattern) {
1567
- let re = "";
1568
1526
  let i = 0;
1569
- while (i < pattern.length) {
1570
- const c = pattern.charAt(i);
1571
- if (c === "*") {
1572
- if (pattern.charAt(i + 1) === "*") {
1573
- i += 2;
1574
- if (pattern.charAt(i) === "/") {
1575
- i += 1;
1576
- 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
+ }
1577
1571
  } else {
1578
- re += ".*";
1572
+ re2 += "[^/]*";
1573
+ i += 1;
1579
1574
  }
1580
- } else {
1581
- re += "[^/]*";
1575
+ continue;
1576
+ }
1577
+ if (c === "?") {
1578
+ re2 += "[^/]";
1582
1579
  i += 1;
1580
+ continue;
1583
1581
  }
1584
- } else if (c === "?") {
1585
- re += "[^/]";
1586
- i += 1;
1587
- } else {
1588
- 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);
1589
1606
  i += 1;
1590
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;
1591
1621
  }
1622
+ const { re } = parseSegment(0);
1592
1623
  return new RegExp(`^${re}$`);
1593
1624
  }
1594
1625
  function matchesGlob(pattern, relPath) {
@@ -1620,10 +1651,10 @@ function parseIgnorePatterns(body) {
1620
1651
  return out;
1621
1652
  }
1622
1653
  function loadIgnorePatterns(dir) {
1623
- const file = path8.join(dir, IGNORE_FILE);
1654
+ const file = path6.join(dir, IGNORE_FILE);
1624
1655
  let mtimeMs;
1625
1656
  try {
1626
- mtimeMs = statSync3(file).mtimeMs;
1657
+ mtimeMs = statSync(file).mtimeMs;
1627
1658
  } catch {
1628
1659
  return [];
1629
1660
  }
@@ -1633,7 +1664,7 @@ function loadIgnorePatterns(dir) {
1633
1664
  }
1634
1665
  let patterns = [];
1635
1666
  try {
1636
- patterns = parseIgnorePatterns(readFileSync3(file, "utf8"));
1667
+ patterns = parseIgnorePatterns(readFileSync(file, "utf8"));
1637
1668
  } catch {
1638
1669
  patterns = [];
1639
1670
  }
@@ -1645,9 +1676,9 @@ function createIgnorePredicate(cwd) {
1645
1676
  if (patterns.length === 0) {
1646
1677
  return void 0;
1647
1678
  }
1648
- const root = path8.resolve(cwd);
1679
+ const root = path6.resolve(cwd);
1649
1680
  return (absPath, isDir) => {
1650
- const rel = path8.relative(root, absPath).split(path8.sep).join("/");
1681
+ const rel = path6.relative(root, absPath).split(path6.sep).join("/");
1651
1682
  if (!rel || rel.startsWith("..")) {
1652
1683
  return false;
1653
1684
  }
@@ -1662,27 +1693,26 @@ function createIgnorePredicate(cwd) {
1662
1693
  return false;
1663
1694
  };
1664
1695
  }
1665
- function isSensitivePath2(filePath) {
1666
- const base = path8.basename(filePath);
1667
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
1668
- return false;
1669
- }
1670
- if (base === ".env" || base.startsWith(".env.")) {
1671
- return true;
1672
- }
1673
- if (base === "id_rsa" || base.endsWith(".pem")) {
1674
- return true;
1675
- }
1676
- return false;
1677
- }
1678
1696
 
1679
1697
  // src/tools/fs/glob.ts
1680
1698
  var MAX_RESULTS = 100;
1681
- var schema3 = z5.object({
1682
- pattern: z5.string().describe(
1683
- "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."
1684
1714
  ),
1685
- 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")
1686
1716
  });
1687
1717
  var globTool = {
1688
1718
  name: "glob",
@@ -1690,47 +1720,72 @@ var globTool = {
1690
1720
  permission: "read",
1691
1721
  parameters: schema3,
1692
1722
  async execute(args, ctx) {
1693
- const root = path9.resolve(ctx.cwd, args.path ?? ".");
1694
- const files = await walkFiles(root, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1695
- 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);
1696
1729
  if (matched.length === 0) {
1697
1730
  return { content: `No files matched pattern: ${args.pattern}` };
1698
1731
  }
1699
- return { content: matched.map((f) => f.rel).join("\n") };
1732
+ return { content: matched.map((f) => `${prefix}${f.rel}`).join("\n") };
1700
1733
  }
1701
1734
  };
1702
1735
 
1703
1736
  // src/tools/fs/grep.ts
1704
- import { readFile as readFile4, stat as stat2 } from "fs/promises";
1705
- import path10 from "path";
1706
- 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";
1707
1740
  var MAX_MATCHES = 250;
1708
- var schema4 = z6.object({
1709
- pattern: z6.string().describe("Regular expression to search for"),
1710
- path: z6.string().optional().describe("File or directory to search, defaults to the working directory"),
1711
- glob: z6.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1712
- 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")
1713
1752
  });
1714
1753
  var grepTool = {
1715
1754
  name: "grep",
1716
- 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.",
1717
1756
  permission: "read",
1718
1757
  parameters: schema4,
1719
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
+ }
1720
1765
  let re;
1721
1766
  try {
1722
1767
  re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
1723
1768
  } catch (err) {
1724
1769
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
1725
1770
  }
1726
- const root = path10.resolve(ctx.cwd, args.path ?? ".");
1771
+ const root = path8.resolve(ctx.cwd, args.path ?? ".");
1727
1772
  let files;
1728
1773
  try {
1729
- const st = await stat2(root);
1774
+ const st = await stat4(root);
1730
1775
  if (st.isFile()) {
1731
- files = [{ abs: root, rel: path10.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 }];
1732
1783
  } else {
1733
- 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));
1734
1789
  }
1735
1790
  } catch {
1736
1791
  return { content: `Path not found: ${args.path ?? "."}`, isError: true };
@@ -1741,10 +1796,19 @@ var grepTool = {
1741
1796
  }
1742
1797
  const out = [];
1743
1798
  let truncated = false;
1799
+ let skippedLarge = 0;
1744
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
+ }
1745
1809
  let buf;
1746
1810
  try {
1747
- buf = await readFile4(file.abs);
1811
+ buf = await readFile3(file.abs);
1748
1812
  } catch {
1749
1813
  continue;
1750
1814
  }
@@ -1755,7 +1819,8 @@ var grepTool = {
1755
1819
  for (let i = 0; i < lines.length; i++) {
1756
1820
  const line = lines[i] ?? "";
1757
1821
  if (re.test(line)) {
1758
- 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}`);
1759
1824
  if (out.length >= MAX_MATCHES) {
1760
1825
  truncated = true;
1761
1826
  break outer;
@@ -1763,26 +1828,55 @@ var grepTool = {
1763
1828
  }
1764
1829
  }
1765
1830
  }
1831
+ const skippedNote = skippedLarge > 0 ? `(skipped ${skippedLarge} file(s) larger than 5MB)` : null;
1766
1832
  if (out.length === 0) {
1767
- return { content: `No matches for pattern: ${args.pattern}` };
1833
+ return {
1834
+ content: `No matches for pattern: ${args.pattern}${skippedNote ? ` ${skippedNote}` : ""}`
1835
+ };
1768
1836
  }
1769
1837
  if (truncated) {
1770
1838
  out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
1771
1839
  }
1840
+ if (skippedNote) {
1841
+ out.push(`... ${skippedNote}`);
1842
+ }
1772
1843
  return { content: out.join("\n") };
1773
1844
  }
1774
1845
  };
1775
1846
 
1776
1847
  // src/tools/fs/read.ts
1777
- import { readFile as readFile5, stat as stat3 } from "fs/promises";
1778
- import path11 from "path";
1779
- 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";
1780
1851
  var DEFAULT_LIMIT = 2e3;
1781
1852
  var MAX_CHARS = 100 * 1024;
1782
- var schema5 = z7.object({
1783
- path: z7.string().describe("File path, absolute or relative to the working directory"),
1784
- offset: z7.number().int().positive().optional().describe("1-based line number to start reading from"),
1785
- 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)")
1786
1880
  });
1787
1881
  var readFileTool = {
1788
1882
  name: "read_file",
@@ -1790,30 +1884,48 @@ var readFileTool = {
1790
1884
  permission: "read",
1791
1885
  parameters: schema5,
1792
1886
  async execute(args, ctx) {
1793
- const filePath = path11.resolve(ctx.cwd, args.path);
1794
- if (isSensitivePath2(filePath)) {
1887
+ const filePath = path9.resolve(ctx.cwd, args.path);
1888
+ if (isSensitivePath(filePath)) {
1795
1889
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1796
1890
  }
1797
- const st = await stat3(filePath).catch(() => null);
1891
+ const st = await stat5(filePath).catch(() => null);
1798
1892
  if (!st) {
1799
1893
  return { content: `File not found: ${args.path}`, isError: true };
1800
1894
  }
1801
1895
  if (st.isDirectory()) {
1802
1896
  return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1803
1897
  }
1804
- const raw = await readFile5(filePath, "utf8");
1805
- 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}`;
1806
1918
  const start = args.offset ?? 1;
1807
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
+ }
1808
1923
  const end = Math.min(lines.length, start - 1 + limit);
1809
1924
  const out = [];
1810
1925
  let size = 0;
1811
1926
  let truncatedBySize = false;
1812
1927
  for (let i = start - 1; i < end; i++) {
1813
- const line = lines[i];
1814
- if (line === void 0) {
1815
- break;
1816
- }
1928
+ const line = (lines[i] ?? "").replace(/\r$/, "");
1817
1929
  const numbered = `${i + 1} ${line}`;
1818
1930
  if (size + numbered.length > MAX_CHARS) {
1819
1931
  truncatedBySize = true;
@@ -1825,22 +1937,22 @@ var readFileTool = {
1825
1937
  const lastShown = start - 1 + out.length;
1826
1938
  if (truncatedBySize) {
1827
1939
  out.push(
1828
- `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${lines.length})`
1940
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
1829
1941
  );
1830
- } else if (end < lines.length) {
1831
- 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})`);
1832
1944
  }
1833
1945
  return { content: out.join("\n") };
1834
1946
  }
1835
1947
  };
1836
1948
 
1837
1949
  // src/tools/fs/write.ts
1838
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
1839
- import path12 from "path";
1840
- import { z as z8 } from "zod";
1841
- var schema6 = z8.object({
1842
- path: z8.string().describe("File path, absolute or relative to the working directory"),
1843
- 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")
1844
1956
  });
1845
1957
  var writeFileTool = {
1846
1958
  name: "write_file",
@@ -1848,11 +1960,11 @@ var writeFileTool = {
1848
1960
  permission: "write",
1849
1961
  parameters: schema6,
1850
1962
  async execute(args, ctx) {
1851
- const filePath = path12.resolve(ctx.cwd, args.path);
1852
- 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);
1853
1965
  try {
1854
- await mkdir3(path12.dirname(filePath), { recursive: true });
1855
- await writeFile4(filePath, args.content, "utf8");
1966
+ await mkdir3(path10.dirname(filePath), { recursive: true });
1967
+ await writeFile3(filePath, args.content, "utf8");
1856
1968
  } catch (err) {
1857
1969
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1858
1970
  }
@@ -1862,7 +1974,118 @@ var writeFileTool = {
1862
1974
  };
1863
1975
 
1864
1976
  // src/tools/tasks.ts
1865
- 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();
1866
2089
 
1867
2090
  // src/tasks/format.ts
1868
2091
  function formatDuration(startedAt, endedAt) {
@@ -1908,9 +2131,14 @@ function formatTaskFinished(task) {
1908
2131
  }
1909
2132
 
1910
2133
  // src/tools/tasks.ts
1911
- var idSchema = z9.object({
1912
- 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")
1913
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
+ }
1914
2142
  function formatAgentTaskLine(task) {
1915
2143
  const duration = formatDuration(task.startedAt, task.endedAt);
1916
2144
  const label = task.description ?? task.prompt.slice(0, 60);
@@ -1921,7 +2149,7 @@ var taskListTool = {
1921
2149
  name: "task_list",
1922
2150
  description: "List background tasks (shell commands and subagents) with status and runtime.",
1923
2151
  permission: "read",
1924
- parameters: z9.object({}),
2152
+ parameters: z7.object({}),
1925
2153
  execute() {
1926
2154
  const shell = formatTaskList(defaultTaskManager.list());
1927
2155
  const agents = defaultAgentTasks.list();
@@ -1939,13 +2167,13 @@ var taskOutputTool = {
1939
2167
  execute(args) {
1940
2168
  const shell = defaultTaskManager.get(args.id);
1941
2169
  if (shell) {
1942
- const body = shell.output.replace(/\s+$/, "") || "(no output yet)";
2170
+ const body = truncateOutput(shell.output.replace(/\s+$/, "") || "(no output yet)");
1943
2171
  return Promise.resolve({ content: `${formatTaskLine(shell)}
1944
2172
  ${body}` });
1945
2173
  }
1946
2174
  const agent = defaultAgentTasks.get(args.id);
1947
2175
  if (agent) {
1948
- 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)");
1949
2177
  return Promise.resolve({ content: `${formatAgentTaskLine(agent)}
1950
2178
  ${body}` });
1951
2179
  }
@@ -1975,21 +2203,154 @@ var taskKillTool = {
1975
2203
  } else {
1976
2204
  defaultAgentTasks.kill(args.id);
1977
2205
  }
1978
- return Promise.resolve({ content: `Task ${args.id} stopped.` });
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);
1979
2268
  }
1980
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()) };
2332
+ }
2333
+ };
2334
+ return [todoWrite, todoRead];
2335
+ }
1981
2336
 
1982
2337
  // src/tools/web/fetch.ts
1983
- import { z as z10 } from "zod";
2338
+ import { z as z9 } from "zod";
1984
2339
  var DEFAULT_MAX_CHARS = 2e4;
1985
2340
  var MAX_BYTES = 2 * 1024 * 1024;
1986
2341
  var TIMEOUT_MS = 3e4;
1987
- var schema7 = z10.object({
1988
- url: z10.string().describe("The http(s) URL to fetch"),
1989
- 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)")
1990
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
+ }
1991
2352
  function decodeEntities(s) {
1992
- 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) => {
1993
2354
  switch (m) {
1994
2355
  case "&quot;":
1995
2356
  return '"';
@@ -2049,7 +2410,7 @@ function truncate2(s, maxChars) {
2049
2410
  }
2050
2411
  var webFetchTool = {
2051
2412
  name: "web_fetch",
2052
- 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.",
2053
2414
  permission: "read",
2054
2415
  parameters: schema7,
2055
2416
  async execute(args, ctx) {
@@ -2114,15 +2475,15 @@ var webFetchTool = {
2114
2475
  };
2115
2476
 
2116
2477
  // src/tools/web/search.ts
2117
- import { z as z11 } from "zod";
2478
+ import { z as z10 } from "zod";
2118
2479
  var TIMEOUT_MS2 = 15e3;
2119
2480
  var DEFAULT_MAX_RESULTS = 5;
2120
2481
  var MAX_RESULTS_LIMIT = 10;
2121
2482
  var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
2122
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";
2123
- var schema8 = z11.object({
2124
- query: z11.string().min(1).describe("The search query"),
2125
- 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(
2126
2487
  `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
2127
2488
  )
2128
2489
  });
@@ -2231,7 +2592,9 @@ var webSearchTool = {
2231
2592
  // src/tools/registry.ts
2232
2593
  var ToolRegistry = class {
2233
2594
  tools = /* @__PURE__ */ new Map();
2234
- 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) {
2235
2598
  for (const tool of [
2236
2599
  readFileTool,
2237
2600
  writeFileTool,
@@ -2244,7 +2607,7 @@ var ToolRegistry = class {
2244
2607
  taskListTool,
2245
2608
  taskOutputTool,
2246
2609
  taskKillTool,
2247
- ...createTodoTools()
2610
+ ...createTodoTools(todoStore)
2248
2611
  ]) {
2249
2612
  this.register(tool);
2250
2613
  }
@@ -2263,13 +2626,165 @@ var ToolRegistry = class {
2263
2626
  }
2264
2627
  };
2265
2628
 
2266
- // src/tools/index.ts
2267
- function createDefaultRegistry() {
2268
- const registry = new ToolRegistry();
2269
- for (const tool of createTodoTools()) {
2270
- 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]`;
2271
2744
  }
2272
- 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);
2273
2788
  }
2274
2789
 
2275
2790
  // src/agent/subagent.ts
@@ -2277,7 +2792,7 @@ var MAX_SUBAGENT_DEPTH = 1;
2277
2792
  var MAX_REPORT_CHARS = 2e4;
2278
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.";
2279
2794
  async function runSubagent(deps, args, cwd, signal) {
2280
- const { AgentLoop: AgentLoop2 } = await import("./loop-XKC5HQBT.js");
2795
+ const { AgentLoop: AgentLoop2 } = await import("./loop-TQ7GFTTE.js");
2281
2796
  const child = new AgentLoop2({
2282
2797
  model: deps.model,
2283
2798
  registry: createDefaultRegistry(),
@@ -2304,6 +2819,9 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2304
2819
  } catch (e) {
2305
2820
  error = e instanceof Error ? e.message : String(e);
2306
2821
  }
2822
+ if (signal.aborted) {
2823
+ throw new Error("Tool execution aborted.");
2824
+ }
2307
2825
  let report = "";
2308
2826
  const messages = child.getMessages();
2309
2827
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2362,13 +2880,13 @@ function createSubagentTool(deps) {
2362
2880
 
2363
2881
  // src/agent/user-memory.ts
2364
2882
  import { appendFileSync, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
2365
- import path13 from "path";
2883
+ import path14 from "path";
2366
2884
  import { z as z13 } from "zod";
2367
2885
  var USER_MEMORY_MAX_CHARS = 32 * 1024;
2368
2886
  var REMEMBER_TEXT_MAX_CHARS = 500;
2369
2887
  var cache3 = /* @__PURE__ */ new Map();
2370
2888
  function readUserMemory(home = starHome()) {
2371
- const file = path13.join(home, "MEMORY.md");
2889
+ const file = path14.join(home, "MEMORY.md");
2372
2890
  let mtimeMs;
2373
2891
  try {
2374
2892
  mtimeMs = statSync4(file).mtimeMs;
@@ -2400,7 +2918,7 @@ function readUserMemory(home = starHome()) {
2400
2918
  }
2401
2919
  function appendUserMemory(text, home = starHome()) {
2402
2920
  mkdirSync(home, { recursive: true });
2403
- appendFileSync(path13.join(home, "MEMORY.md"), `- ${text}
2921
+ appendFileSync(path14.join(home, "MEMORY.md"), `- ${text}
2404
2922
  `, "utf8");
2405
2923
  }
2406
2924
  function createRememberTool(deps = {}) {
@@ -2467,6 +2985,24 @@ ${finalReply.slice(0, 2e3)}
2467
2985
  }
2468
2986
  }
2469
2987
  var STREAM_RETRY_BASE_DELAY_MS = 1e3;
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(",")}}`;
3005
+ }
2470
3006
  function sleep(ms, signal) {
2471
3007
  if (signal.aborted) return Promise.resolve(false);
2472
3008
  return new Promise((resolve) => {
@@ -2485,11 +3021,32 @@ var AgentLoop = class {
2485
3021
  messages = [];
2486
3022
  opts;
2487
3023
  // Seq + user-message index of each turn started via stream(); lets /undo
2488
- // match a retracted turn to the file snapshots it produced. Cleared when
2489
- // history is replaced wholesale (resume/compact), because indices no longer
2490
- // 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.
2491
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;
2492
3048
  titleScheduled = false;
3049
+ titleInput;
2493
3050
  confirmHandler;
2494
3051
  // Hook failures never block the turn (except an explicit PreToolUse block);
2495
3052
  // their stderr surfaces through this callback (REPL system message / stderr
@@ -2497,6 +3054,15 @@ var AgentLoop = class {
2497
3054
  onHookWarning;
2498
3055
  constructor(opts) {
2499
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
+ }
2500
3066
  if (opts.system) {
2501
3067
  this.messages.push({ role: "system", content: opts.system });
2502
3068
  }
@@ -2505,7 +3071,6 @@ var AgentLoop = class {
2505
3071
  }
2506
3072
  opts.registry?.register(createSkillTool({ cwd: opts.cwd }));
2507
3073
  opts.registry?.register(createRememberTool());
2508
- const depth = opts.subagentDepth ?? 0;
2509
3074
  if (opts.registry && depth < MAX_SUBAGENT_DEPTH) {
2510
3075
  opts.registry.register(
2511
3076
  createSubagentTool({
@@ -2524,10 +3089,14 @@ var AgentLoop = class {
2524
3089
  }
2525
3090
  // Swaps the persistence target (/new starts a fresh session mid-REPL).
2526
3091
  // Title generation is re-armed so the new session gets one after its first
2527
- // 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.
2528
3095
  setSessionStore(store) {
2529
3096
  this.opts.sessionStore = store;
2530
3097
  this.titleScheduled = false;
3098
+ this.titleInput = void 0;
3099
+ this.redoStack = [];
2531
3100
  if (store) this.bindSnapshotHooks(store);
2532
3101
  }
2533
3102
  bindSnapshotHooks(store) {
@@ -2540,7 +3109,9 @@ var AgentLoop = class {
2540
3109
  existed: snapshot.existed,
2541
3110
  toolName: snapshot.toolName,
2542
3111
  turn: snapshot.turn,
2543
- messageIndex: snapshot.messageIndex
3112
+ messageIndex: snapshot.messageIndex,
3113
+ owner: snapshot.owner,
3114
+ contentTooLarge: snapshot.contentTooLarge
2544
3115
  },
2545
3116
  snapshot.content
2546
3117
  ),
@@ -2550,37 +3121,92 @@ var AgentLoop = class {
2550
3121
  async loadMessages(messages) {
2551
3122
  this.messages = reconcileToolCalls(messages);
2552
3123
  this.turnMarkers = [];
3124
+ this.redoStack = [];
2553
3125
  }
2554
3126
  // Read-only counterpart of retractLastTurn: how many messages /undo would
2555
- // drop and which snapshot turn's file changes it would revert (undefined =
2556
- // no verified turn, so no file snapshots may be reverted). Mutates nothing,
2557
- // 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.
2558
3131
  previewLastTurnRetraction() {
2559
3132
  const result = retractLastTurn([...this.messages]);
2560
3133
  if (result.removed === 0) return { removed: 0 };
2561
3134
  const userIndex = result.messages.length;
2562
3135
  const last = this.turnMarkers[this.turnMarkers.length - 1];
2563
- const turn = last && last.userIndex === userIndex ? last.seq : void 0;
2564
- 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
+ };
2565
3142
  }
2566
3143
  // Drops the final user message and everything after it, and persists the
2567
3144
  // trimmed history so a later /resume does not bring the turn back.
2568
3145
  // `turn` is the retracted turn's snapshot seq when it could be verified
2569
3146
  // against the marker recorded at turn start — undefined means the caller
2570
- // 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.
2571
3149
  async retractLastTurn() {
2572
3150
  const result = retractLastTurn([...this.messages]);
2573
3151
  if (result.removed === 0) return { removed: 0 };
2574
3152
  const userIndex = result.messages.length;
3153
+ this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
2575
3154
  this.messages = result.messages;
2576
3155
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2577
3156
  const last = this.turnMarkers[this.turnMarkers.length - 1];
2578
3157
  let turn;
3158
+ let tree;
2579
3159
  if (last && last.userIndex === userIndex) {
2580
3160
  turn = last.seq;
3161
+ tree = last.tree;
2581
3162
  this.turnMarkers.pop();
2582
3163
  }
2583
- 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 };
2584
3210
  }
2585
3211
  // Cut point for a rewind: messages[index] should be the user message that
2586
3212
  // started the rewound turn, but compaction may have shifted indices, so
@@ -2626,13 +3252,18 @@ var AgentLoop = class {
2626
3252
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2627
3253
  return removed;
2628
3254
  }
2629
- // After the first assistant reply, kick off background title generation
2630
- // for the session. Runs once per loop; the store-level title check makes
2631
- // resumed sessions that already have a title a no-op.
2632
- 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() {
2633
3262
  if (this.titleScheduled) return;
2634
3263
  const store = this.opts.sessionStore;
2635
3264
  if (!store) return;
3265
+ const input = this.titleInput;
3266
+ if (!input) return;
2636
3267
  this.titleScheduled = true;
2637
3268
  scheduleSessionTitle(store, input, this.opts.model);
2638
3269
  }
@@ -2681,6 +3312,9 @@ var AgentLoop = class {
2681
3312
  this.syncSystemMessage();
2682
3313
  const inputText = typeof input === "string" ? input : input.text;
2683
3314
  const images = typeof input === "string" ? [] : input.images;
3315
+ if (this.titleInput === void 0 && inputText.trim().length > 0) {
3316
+ this.titleInput = inputText;
3317
+ }
2684
3318
  const userMessage = images.length > 0 ? {
2685
3319
  role: "user",
2686
3320
  content: [
@@ -2692,12 +3326,17 @@ var AgentLoop = class {
2692
3326
  { type: "text", text: inputText }
2693
3327
  ]
2694
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;
2695
3331
  this.messages.push(userMessage);
2696
- const seq = (this.opts.subagentDepth ?? 0) === 0 ? beginTurn(this.messages.length - 1) : currentTurnSeq();
2697
- 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;
2698
3336
  await this.persist(
2699
3337
  opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
2700
3338
  );
3339
+ this.maybeScheduleTitle();
2701
3340
  const { config, registry, cwd } = this.opts;
2702
3341
  const aiTools = this.buildAiTools();
2703
3342
  let autoContinues = 0;
@@ -2720,7 +3359,8 @@ ${finished.result}`
2720
3359
  const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2721
3360
  const compacted = compactMessages([...this.messages], maxTokens);
2722
3361
  if (compacted.compacted) {
2723
- this.messages = await this.applyCompactionSummary(compacted);
3362
+ this.messages = await this.applyCompactionSummary(compacted, signal);
3363
+ this.turnMarkers = [];
2724
3364
  }
2725
3365
  const maxRetries = Math.max(0, config.streamMaxRetries);
2726
3366
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
@@ -2793,7 +3433,10 @@ ${finished.result}`
2793
3433
  delayMs,
2794
3434
  reason: failure ? summarizeStreamError(failure) : `empty response (finish: ${lastFinishReason ?? "none"})`
2795
3435
  };
2796
- if (!await sleep(delayMs, signal)) return;
3436
+ if (!await sleep(delayMs, signal)) {
3437
+ yield* this.persistInterrupted(text, toolCalls);
3438
+ return;
3439
+ }
2797
3440
  }
2798
3441
  if (failure) {
2799
3442
  yield { type: "error", error: failure };
@@ -2835,7 +3478,6 @@ ${finished.result}`
2835
3478
  };
2836
3479
  this.messages.push(assistantMessage);
2837
3480
  await this.persist(assistantMessage);
2838
- this.maybeScheduleTitle(text);
2839
3481
  if (toolCalls.length === 0) {
2840
3482
  let reason = null;
2841
3483
  let nudgeText = AUTO_CONTINUE_NUDGE;
@@ -2883,7 +3525,11 @@ ${finished.result}`
2883
3525
  try {
2884
3526
  for (const call of toolCalls) {
2885
3527
  if (signal.aborted) break;
2886
- 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);
2887
3533
  const toolMessage = {
2888
3534
  role: "tool",
2889
3535
  content: [
@@ -2960,7 +3606,7 @@ ${finished.result}`
2960
3606
  if (userMemory) parts.push(userMemory);
2961
3607
  const skills = discoverSkills(this.opts.cwd);
2962
3608
  if (skills.length > 0) parts.push(formatSkillsBlock(skills));
2963
- const git = getGitSummary(this.opts.cwd);
3609
+ const git = getGitSummaryCached(this.opts.cwd);
2964
3610
  if (git) parts.push(formatGitSummary(git));
2965
3611
  if (parts.length === 0) return;
2966
3612
  const content = parts.join("\n\n");
@@ -2971,7 +3617,7 @@ ${finished.result}`
2971
3617
  this.messages.unshift({ role: "system", content });
2972
3618
  }
2973
3619
  }
2974
- async applyCompactionSummary(compacted) {
3620
+ async applyCompactionSummary(compacted, signal) {
2975
3621
  const { config, model } = this.opts;
2976
3622
  if (config.contextCompaction !== "summary" || !model) {
2977
3623
  return compacted.messages;
@@ -2979,7 +3625,7 @@ ${finished.result}`
2979
3625
  const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
2980
3626
  const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
2981
3627
  try {
2982
- const summary = await summarizeMessages(dropped, model);
3628
+ const summary = await summarizeMessages(dropped, model, signal);
2983
3629
  const messages = compacted.messages.slice();
2984
3630
  messages[headCount] = {
2985
3631
  role: "user",
@@ -2992,15 +3638,23 @@ ${summary}`
2992
3638
  }
2993
3639
  }
2994
3640
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
2995
- const { streamChat } = await import("./stream-OY2MKWYE.js");
2996
- yield* streamChat({
3641
+ const { streamChat } = await import("./stream-H4BYBVAZ.js");
3642
+ for await (const event of streamChat({
2997
3643
  model: this.opts.model,
2998
3644
  messages: this.messages,
2999
3645
  tools: aiTools,
3000
3646
  abortSignal: signal,
3001
3647
  idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
3002
3648
  firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
3003
- });
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
+ }
3004
3658
  }
3005
3659
  buildAiTools() {
3006
3660
  const plan = this.opts.config.permissionMode === "plan";
@@ -3030,6 +3684,23 @@ ${summary}`
3030
3684
  return empty;
3031
3685
  }
3032
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
+ }
3033
3704
  async executeTool(call, signal) {
3034
3705
  const { registry, config, cwd } = this.opts;
3035
3706
  const tool = registry.get(call.name);
@@ -3074,7 +3745,15 @@ ${summary}`
3074
3745
  }
3075
3746
  let result;
3076
3747
  try {
3077
- 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
+ });
3078
3757
  } catch (error) {
3079
3758
  if (signal.aborted) {
3080
3759
  return { content: "Tool execution aborted.", isError: true };
@@ -3105,34 +3784,34 @@ export {
3105
3784
  MAX_IMAGE_DIMENSION,
3106
3785
  probeImageDimensions,
3107
3786
  reconcileToolCalls,
3787
+ isSensitivePath,
3108
3788
  isAllowedByRules,
3109
3789
  buildAllowRule,
3110
3790
  isDangerousCommand,
3791
+ diffTreeNames,
3111
3792
  listSnapshots,
3112
3793
  clearSnapshots,
3113
3794
  hydrateSnapshots,
3114
3795
  listTurnSnapshots,
3115
3796
  resolveSnapshotContent,
3116
- undoTurnSnapshots,
3117
3797
  rewindToSnapshot,
3118
- TodoStore,
3119
- formatTodos,
3120
- parseTodoArgs,
3121
- loadTodos,
3122
- resetTodos,
3123
- discoverSkills,
3124
3798
  defaultTaskManager,
3125
3799
  resolveShell,
3126
3800
  bashTool,
3127
3801
  SKIP_DIRS,
3128
3802
  createIgnorePredicate,
3129
- isSensitivePath2 as isSensitivePath,
3130
3803
  formatTaskList,
3131
3804
  formatTaskStarted,
3132
3805
  formatTaskFinished,
3806
+ TodoStore,
3807
+ formatTodos,
3808
+ parseTodoArgs,
3809
+ loadTodos,
3810
+ resetTodos,
3811
+ discoverSkills,
3133
3812
  createDefaultRegistry,
3134
3813
  appendUserMemory,
3135
3814
  PLAN_MODE_PROMPT,
3136
3815
  AgentLoop
3137
3816
  };
3138
- //# sourceMappingURL=chunk-BV4PLM6X.js.map
3817
+ //# sourceMappingURL=chunk-3L6QKJM4.js.map