minimal-agent 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -2,7 +2,19 @@
2
2
 
3
3
  // src/main.tsx
4
4
  import { render } from "ink";
5
+ import { existsSync as existsSync7, mkdirSync } from "fs";
5
6
  import { createRequire } from "module";
7
+ import { resolve as resolve8 } from "path";
8
+
9
+ // src/bootstrap/cwdArg.ts
10
+ function extractCwdArg(argv) {
11
+ for (let i = 0; i < argv.length; i++) {
12
+ if (argv[i] === "-d" || argv[i] === "--cwd") {
13
+ return argv[i + 1] ?? null;
14
+ }
15
+ }
16
+ return null;
17
+ }
6
18
 
7
19
  // src/bootstrap/workingDir.ts
8
20
  import { resolve } from "path";
@@ -57,42 +69,6 @@ async function saveConfig(cfg) {
57
69
 
58
70
  // src/config.ts
59
71
  var DEFAULT_CONTEXT_WINDOW = 128e3;
60
- async function loadProvider() {
61
- const baseURL = process.env.MINIMAL_AGENT_BASE_URL;
62
- const apiKey = process.env.MINIMAL_AGENT_API_KEY;
63
- const model = process.env.MINIMAL_AGENT_MODEL;
64
- if (!baseURL || !apiKey || !model) {
65
- const missing = [];
66
- if (!baseURL) missing.push("MINIMAL_AGENT_BASE_URL");
67
- if (!apiKey) missing.push("MINIMAL_AGENT_API_KEY");
68
- if (!model) missing.push("MINIMAL_AGENT_MODEL");
69
- throw new Error(
70
- `\u7F3A\u5C11\u5FC5\u9700\u7684\u73AF\u5883\u53D8\u91CF\uFF1A${missing.join(", ")}
71
-
72
- \u8BF7\u5728 .env \u4E2D\u914D\u7F6E\uFF1A
73
- MINIMAL_AGENT_BASE_URL=https://api.example.com/v1
74
- MINIMAL_AGENT_API_KEY=your-api-key
75
- MINIMAL_AGENT_MODEL=your-model
76
-
77
- \u53C2\u8003 .env.example`
78
- );
79
- }
80
- const contextWindowRaw = process.env.MINIMAL_AGENT_CONTEXT_WINDOW;
81
- let contextWindow = DEFAULT_CONTEXT_WINDOW;
82
- if (contextWindowRaw) {
83
- const n = parseInt(contextWindowRaw, 10);
84
- if (!Number.isNaN(n) && n > 0) {
85
- contextWindow = n;
86
- }
87
- }
88
- return {
89
- name: process.env.MINIMAL_AGENT_PROVIDER ?? "env",
90
- baseURL,
91
- apiKey,
92
- model,
93
- contextWindow
94
- };
95
- }
96
72
  async function loadProviderLayered() {
97
73
  const envBaseURL = process.env.MINIMAL_AGENT_BASE_URL;
98
74
  const envApiKey = process.env.MINIMAL_AGENT_API_KEY;
@@ -124,8 +100,8 @@ async function loadProviderLayered() {
124
100
  }
125
101
 
126
102
  // src/context/persistContext.ts
127
- import { mkdir as mkdir3, readFile as readFile2, unlink, writeFile as writeFile2 } from "fs/promises";
128
- import { dirname as dirname3 } from "path";
103
+ import { mkdir as mkdir3, readFile as readFile2, readdir, rmdir, unlink, writeFile as writeFile2 } from "fs/promises";
104
+ import { dirname as dirname3, join as join3 } from "path";
129
105
 
130
106
  // src/context/sessionPath.ts
131
107
  import { createHash } from "crypto";
@@ -191,6 +167,30 @@ async function clearContext(file) {
191
167
  await unlink(target);
192
168
  } catch {
193
169
  }
170
+ const cwd = getWorkingDir();
171
+ let topEntries;
172
+ try {
173
+ topEntries = await readdir(cwd);
174
+ } catch {
175
+ return;
176
+ }
177
+ const stateDirs = topEntries.filter(
178
+ (name) => name === ".minimal-agent" || name.startsWith(".minimal-agent-")
179
+ );
180
+ for (const name of stateDirs) {
181
+ const dir = join3(cwd, name);
182
+ try {
183
+ const entries = await readdir(dir);
184
+ for (const entry of entries) {
185
+ try {
186
+ await unlink(join3(dir, entry));
187
+ } catch {
188
+ }
189
+ }
190
+ await rmdir(dir);
191
+ } catch {
192
+ }
193
+ }
194
194
  }
195
195
 
196
196
  // src/prompts/system.ts
@@ -198,10 +198,10 @@ import { homedir as homedir3 } from "os";
198
198
 
199
199
  // src/prompts/projectInstructions.ts
200
200
  import { readFile as readFile3 } from "fs/promises";
201
- import { join as join3 } from "path";
201
+ import { join as join4 } from "path";
202
202
  var FILENAME = "minimal-agent.md";
203
203
  async function loadProjectInstructions(cwd) {
204
- const filePath = join3(cwd, FILENAME);
204
+ const filePath = join4(cwd, FILENAME);
205
205
  try {
206
206
  const content = await readFile3(filePath, "utf-8");
207
207
  const trimmed = content.trim();
@@ -220,8 +220,8 @@ async function loadProjectInstructions(cwd) {
220
220
  }
221
221
 
222
222
  // src/prompts/skillList.ts
223
- import { readFile as readFile4, readdir } from "fs/promises";
224
- import { join as join4 } from "path";
223
+ import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
224
+ import { join as join5 } from "path";
225
225
 
226
226
  // src/utils/packageRoot.ts
227
227
  import { existsSync } from "fs";
@@ -246,7 +246,7 @@ function findPackageRoot(metaUrl) {
246
246
  }
247
247
 
248
248
  // src/prompts/skillList.ts
249
- var SKILLS_DIR = join4(findPackageRoot(import.meta.url), "skills");
249
+ var SKILLS_DIR = join5(findPackageRoot(import.meta.url), "skills");
250
250
  function stripQuotes(s) {
251
251
  const trimmed = s.trim();
252
252
  if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
@@ -273,10 +273,10 @@ function parseFrontmatter(content) {
273
273
  async function getSkillList() {
274
274
  const skills = [];
275
275
  try {
276
- const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
276
+ const entries = await readdir2(SKILLS_DIR, { withFileTypes: true });
277
277
  for (const entry of entries) {
278
278
  if (!entry.isDirectory()) continue;
279
- const skillPath = join4(SKILLS_DIR, entry.name, "SKILL.md");
279
+ const skillPath = join5(SKILLS_DIR, entry.name, "SKILL.md");
280
280
  try {
281
281
  const content = await readFile4(skillPath, "utf8");
282
282
  const meta = parseFrontmatter(content);
@@ -693,10 +693,171 @@ var bashTool = {
693
693
  };
694
694
 
695
695
  // src/tools/edit/edit.ts
696
- import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
696
+ import { readFile as readFile6, writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
697
697
  import { existsSync as existsSync2 } from "fs";
698
- import { dirname as dirname5, resolve as resolve4 } from "path";
698
+ import { dirname as dirname5 } from "path";
699
699
  import { z as z2 } from "zod";
700
+
701
+ // src/tools/shared/fileUtils.ts
702
+ import { readFile as readFile5 } from "fs/promises";
703
+ import { homedir as homedir4 } from "os";
704
+ import { resolve as resolve4, normalize } from "path";
705
+ var BLOCKED_DEVICE_PATHS = /* @__PURE__ */ new Set([
706
+ "/dev/zero",
707
+ "/dev/random",
708
+ "/dev/urandom",
709
+ "/dev/full",
710
+ "/dev/stdin",
711
+ "/dev/tty",
712
+ "/dev/console",
713
+ "/dev/stdout",
714
+ "/dev/stderr",
715
+ "/dev/fd/0",
716
+ "/dev/fd/1",
717
+ "/dev/fd/2"
718
+ ]);
719
+ var WINDOWS_BLOCKED_NAMES = /* @__PURE__ */ new Set(["NUL", "CON", "PRN", "AUX", "COM1", "COM2", "LPT1"]);
720
+ function isBlockedDevicePath(filePath) {
721
+ const normalized = normalize(filePath);
722
+ if (BLOCKED_DEVICE_PATHS.has(normalized)) return true;
723
+ if (normalized.startsWith("/proc/") && (normalized.endsWith("/fd/0") || normalized.endsWith("/fd/1") || normalized.endsWith("/fd/2"))) {
724
+ return true;
725
+ }
726
+ const baseName = normalized.split(/[/\\]/).pop() ?? "";
727
+ if (WINDOWS_BLOCKED_NAMES.has(baseName.toUpperCase())) {
728
+ return true;
729
+ }
730
+ return false;
731
+ }
732
+ function validateAndResolvePath(rawPath, workingDir) {
733
+ if (rawPath.includes("\0")) {
734
+ return { ok: false, error: "\u8DEF\u5F84\u5305\u542B\u975E\u6CD5\u5B57\u7B26\uFF08null byte\uFF09" };
735
+ }
736
+ const expanded = expandPath(rawPath);
737
+ const resolved = resolve4(workingDir, expanded);
738
+ if (isBlockedDevicePath(resolved)) {
739
+ return { ok: false, error: `\u4E0D\u5141\u8BB8\u8BFB\u53D6\u8BBE\u5907\u6587\u4EF6\uFF1A${resolved}\u3002\u8BE5\u8DEF\u5F84\u53EF\u80FD\u4EA7\u751F\u65E0\u9650\u8F93\u51FA\u6216\u963B\u585E\u8FDB\u7A0B\u3002` };
740
+ }
741
+ if (process.platform === "win32" && /^\\\\/.test(resolved)) {
742
+ return { ok: false, error: "\u4E0D\u652F\u6301 UNC \u8DEF\u5F84\uFF08\\\\server\\share \u683C\u5F0F\uFF09\uFF0C\u8BF7\u4F7F\u7528\u672C\u5730\u8DEF\u5F84" };
743
+ }
744
+ return { ok: true, resolvedPath: resolved };
745
+ }
746
+ function expandPath(p) {
747
+ if (p.startsWith("~/") || p === "~") {
748
+ return resolve4(homedir4(), p.slice(2));
749
+ }
750
+ return p;
751
+ }
752
+ function detectLineEndingsForString(content) {
753
+ let crlfCount = 0;
754
+ let lfCount = 0;
755
+ for (let i = 0; i < content.length; i++) {
756
+ if (content[i] === "\n") {
757
+ if (i > 0 && content[i - 1] === "\r") {
758
+ crlfCount++;
759
+ } else {
760
+ lfCount++;
761
+ }
762
+ }
763
+ }
764
+ return crlfCount > lfCount ? "CRLF" : "LF";
765
+ }
766
+ async function detectFileLineEndings(filePath) {
767
+ try {
768
+ const handle = await readFile5(filePath, { encoding: "utf8" });
769
+ const head = handle.slice(0, 4096);
770
+ return detectLineEndingsForString(head);
771
+ } catch {
772
+ return "LF";
773
+ }
774
+ }
775
+ function applyLineEnding(content, ending) {
776
+ if (ending === "CRLF") {
777
+ return content.replaceAll("\r\n", "\n").split("\n").join("\r\n");
778
+ }
779
+ return content;
780
+ }
781
+ var LEFT_SINGLE_CURLY_QUOTE = "\u2018";
782
+ var RIGHT_SINGLE_CURLY_QUOTE = "\u2019";
783
+ var LEFT_DOUBLE_CURLY_QUOTE = "\u201C";
784
+ var RIGHT_DOUBLE_CURLY_QUOTE = "\u201D";
785
+ function normalizeQuotes(str) {
786
+ return str.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'").replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'").replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"').replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
787
+ }
788
+ function findActualString(fileContent, searchString) {
789
+ if (fileContent.includes(searchString)) {
790
+ return searchString;
791
+ }
792
+ const normalizedSearch = normalizeQuotes(searchString);
793
+ const normalizedFile = normalizeQuotes(fileContent);
794
+ const searchIndex = normalizedFile.indexOf(normalizedSearch);
795
+ if (searchIndex !== -1) {
796
+ return fileContent.substring(searchIndex, searchIndex + searchString.length);
797
+ }
798
+ return null;
799
+ }
800
+ function preserveQuoteStyle(oldString, actualOldString, newString) {
801
+ if (oldString === actualOldString) {
802
+ return newString;
803
+ }
804
+ const hasDoubleQuotes = actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) || actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE);
805
+ const hasSingleQuotes = actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) || actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE);
806
+ if (!hasDoubleQuotes && !hasSingleQuotes) {
807
+ return newString;
808
+ }
809
+ let result = newString;
810
+ if (hasDoubleQuotes) {
811
+ result = applyCurlyDoubleQuotes(result);
812
+ }
813
+ if (hasSingleQuotes) {
814
+ result = applyCurlySingleQuotes(result);
815
+ }
816
+ return result;
817
+ }
818
+ function isOpeningContext(chars, index) {
819
+ if (index === 0) return true;
820
+ const prev = chars[index - 1];
821
+ return prev === " " || prev === " " || prev === "\n" || prev === "\r" || prev === "(" || prev === "[" || prev === "{" || prev === "\u2014" || prev === "\u2013";
822
+ }
823
+ function applyCurlyDoubleQuotes(str) {
824
+ const chars = [...str];
825
+ const result = [];
826
+ for (let i = 0; i < chars.length; i++) {
827
+ if (chars[i] === '"') {
828
+ result.push(
829
+ isOpeningContext(chars, i) ? LEFT_DOUBLE_CURLY_QUOTE : RIGHT_DOUBLE_CURLY_QUOTE
830
+ );
831
+ } else {
832
+ result.push(chars[i]);
833
+ }
834
+ }
835
+ return result.join("");
836
+ }
837
+ function applyCurlySingleQuotes(str) {
838
+ const chars = [...str];
839
+ const result = [];
840
+ for (let i = 0; i < chars.length; i++) {
841
+ if (chars[i] === "'") {
842
+ const prev = i > 0 ? chars[i - 1] : void 0;
843
+ const next = i < chars.length - 1 ? chars[i + 1] : void 0;
844
+ const prevIsLetter = prev !== void 0 && new RegExp("\\p{L}", "u").test(prev);
845
+ const nextIsLetter = next !== void 0 && new RegExp("\\p{L}", "u").test(next);
846
+ if (prevIsLetter && nextIsLetter) {
847
+ result.push(RIGHT_SINGLE_CURLY_QUOTE);
848
+ } else {
849
+ result.push(
850
+ isOpeningContext(chars, i) ? LEFT_SINGLE_CURLY_QUOTE : RIGHT_SINGLE_CURLY_QUOTE
851
+ );
852
+ }
853
+ } else {
854
+ result.push(chars[i]);
855
+ }
856
+ }
857
+ return result.join("");
858
+ }
859
+
860
+ // src/tools/edit/edit.ts
700
861
  var MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
701
862
  var inputSchema2 = z2.object({
702
863
  file_path: z2.string().min(1).describe("\u8981\u7F16\u8F91\u7684\u6587\u4EF6\u8DEF\u5F84"),
@@ -718,21 +879,12 @@ Usage:
718
879
  - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
719
880
  - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
720
881
  - Preserve exact indentation (tabs/spaces).`;
721
- function validatePath(filePath) {
722
- if (filePath.includes("\0")) {
723
- return { ok: false, error: "\u8DEF\u5F84\u5305\u542B\u975E\u6CD5\u5B57\u7B26\uFF08null byte\uFF09" };
724
- }
725
- if (process.platform === "win32" && filePath.includes("\\\\")) {
726
- return { ok: false, error: "\u4E0D\u652F\u6301 UNC \u8DEF\u5F84\uFF08\\\\server\\share \u683C\u5F0F\uFF09\uFF0C\u8BF7\u4F7F\u7528\u672C\u5730\u8DEF\u5F84" };
727
- }
728
- return { ok: true };
729
- }
730
882
  async function call2(input) {
731
- const filePath = resolve4(input.file_path);
732
883
  const { old_string, new_string } = input;
733
884
  const replaceAll = input.replace_all ?? false;
734
- const pathCheck = validatePath(filePath);
735
- if (!pathCheck.ok) return pathCheck;
885
+ const pathResult = validateAndResolvePath(input.file_path, getWorkingDir());
886
+ if (!pathResult.ok) return pathResult;
887
+ const filePath = pathResult.resolvedPath;
736
888
  if (old_string === new_string) {
737
889
  return { ok: false, error: "old_string \u4E0E new_string \u5B8C\u5168\u76F8\u540C\uFF0C\u6CA1\u6709\u53EF\u6539\u7684\u5185\u5BB9\u3002" };
738
890
  }
@@ -757,10 +909,11 @@ async function call2(input) {
757
909
  }
758
910
  let original;
759
911
  try {
760
- original = await readFile5(filePath, "utf8");
912
+ original = await readFile6(filePath, "utf8");
761
913
  } catch (e) {
762
914
  return { ok: false, error: `\u8BFB\u53D6\u5931\u8D25\uFF1A${e.message}` };
763
915
  }
916
+ const originalLineEnding = detectLineEndingsForString(original);
764
917
  const fileSize = Buffer.byteLength(original, "utf8");
765
918
  if (fileSize > MAX_EDIT_FILE_SIZE_BYTES) {
766
919
  return {
@@ -774,9 +927,16 @@ async function call2(input) {
774
927
  error: "old_string \u4E3A\u7A7A\u4F46\u6587\u4EF6\u5DF2\u5B58\u5728 \u2014\u2014 \u8FD9\u901A\u5E38\u662F\u9519\u8BEF\u7684\u3002\u8981\u66FF\u6362\u5168\u6587\u8BF7\u7528 Write \u5DE5\u5177\u3002"
775
928
  };
776
929
  }
777
- const occurrences = countOccurrences(original, old_string);
930
+ let searchTarget = old_string;
931
+ let processedNewString = new_string;
932
+ const actualOld = findActualString(original, old_string);
933
+ if (actualOld !== null && actualOld !== old_string) {
934
+ searchTarget = actualOld;
935
+ processedNewString = preserveQuoteStyle(old_string, actualOld, new_string);
936
+ }
937
+ const occurrences = countOccurrences(original, searchTarget);
778
938
  if (occurrences === 0) {
779
- const hint = findFuzzyMatchHint(original, old_string);
939
+ const hint = findFuzzyMatchHint(original, searchTarget);
780
940
  const extraMsg = hint ? `
781
941
 
782
942
  \u{1F4A1} \u63D0\u793A\uFF1A${hint}` : "";
@@ -792,9 +952,10 @@ async function call2(input) {
792
952
  \u8BF7\u6269\u5927 old_string \u5305\u542B\u66F4\u591A\u4E0A\u4E0B\u6587\uFF0C\u6216\u663E\u5F0F\u4F20 replace_all=true\u3002`
793
953
  };
794
954
  }
795
- const replaced = replaceAll ? splitReplaceAll(original, old_string, new_string) : original.replace(old_string, new_string);
955
+ const replaced = replaceAll ? splitReplaceAll(original, searchTarget, processedNewString) : original.replace(searchTarget, processedNewString);
956
+ const normalizedReplaced = applyLineEnding(replaced, originalLineEnding);
796
957
  try {
797
- await writeFile3(filePath, replaced, "utf8");
958
+ await writeFile3(filePath, normalizedReplaced, "utf8");
798
959
  } catch (e) {
799
960
  return { ok: false, error: `\u5199\u5165\u5931\u8D25\uFF1A${e.message}` };
800
961
  }
@@ -1190,8 +1351,9 @@ var grepTool = {
1190
1351
  };
1191
1352
 
1192
1353
  // src/tools/read/read.ts
1193
- import { readFile as readFile6, stat as stat3 } from "fs/promises";
1194
- import { resolve as resolve8 } from "path";
1354
+ import { createReadStream } from "fs";
1355
+ import { readFile as readFile7, stat as stat3 } from "fs/promises";
1356
+ import { createInterface } from "readline";
1195
1357
  import { z as z5 } from "zod";
1196
1358
  var inputSchema5 = z5.object({
1197
1359
  file_path: z5.string().min(1, "\u5FC5\u987B\u63D0\u4F9B file_path").describe("\u8981\u8BFB\u53D6\u7684\u6587\u4EF6\u8DEF\u5F84\uFF0C\u7EDD\u5BF9\u8DEF\u5F84\u4F18\u5148"),
@@ -1209,10 +1371,18 @@ Usage:
1209
1371
  - Results are returned using cat -n format, with line numbers starting at 1
1210
1372
  - This tool can only read text files, not directories. To read a directory, use the Glob tool.
1211
1373
  - If you read a file that exists but has empty contents you will receive a warning in place of file contents.`;
1374
+ var STREAM_THRESHOLD = 1024 * 1024;
1212
1375
  async function call5(input) {
1213
- const filePath = resolve8(input.file_path);
1214
1376
  const offset = input.offset ?? 1;
1215
1377
  const limit = input.limit ?? MAX_LINES_TO_READ;
1378
+ const pathResult = validateAndResolvePath(input.file_path, getWorkingDir());
1379
+ if (!pathResult.ok) {
1380
+ return { ok: false, error: pathResult.error };
1381
+ }
1382
+ const filePath = pathResult.resolvedPath;
1383
+ if (isBlockedDevicePath(filePath)) {
1384
+ return { ok: false, error: `\u4E0D\u5141\u8BB8\u8BFB\u53D6\u8BBE\u5907\u6587\u4EF6\uFF1A${filePath}\u3002\u8BE5\u8DEF\u5F84\u53EF\u80FD\u4EA7\u751F\u65E0\u9650\u8F93\u51FA\u6216\u963B\u585E\u8FDB\u7A0B\u3002` };
1385
+ }
1216
1386
  let st;
1217
1387
  try {
1218
1388
  st = await stat3(filePath);
@@ -1231,33 +1401,37 @@ async function call5(input) {
1231
1401
  error: `\u6587\u4EF6\u8FC7\u5927\uFF08${st.size} \u5B57\u8282 > ${MAX_FILE_SIZE_BYTES}\uFF09\u3002\u8BF7\u7528 offset/limit \u5206\u6BB5\u8BFB\u3002`
1232
1402
  };
1233
1403
  }
1234
- let raw;
1235
- try {
1236
- raw = await readFile6(filePath, "utf8");
1237
- } catch (e) {
1238
- return { ok: false, error: `\u8BFB\u53D6\u5931\u8D25\uFF1A${e.message}` };
1404
+ let numbered;
1405
+ let totalLines;
1406
+ if (st.size <= STREAM_THRESHOLD) {
1407
+ const result = await readSmallFile(filePath, offset, limit);
1408
+ numbered = result.numbered;
1409
+ totalLines = result.totalLines;
1410
+ if (result.isEmpty) {
1411
+ return { ok: true, content: "<file is empty>" };
1412
+ }
1413
+ } else {
1414
+ const result = await readLargeFileStream(filePath, offset, limit);
1415
+ numbered = result.numbered;
1416
+ totalLines = result.totalLines;
1239
1417
  }
1240
- if (raw.length === 0) {
1418
+ if (totalLines === 0 || !numbered) {
1241
1419
  return { ok: true, content: "<file is empty>" };
1242
1420
  }
1243
- const allLines = raw.split("\n");
1244
- const totalLines = allLines.length;
1245
- const startIdx = Math.max(0, offset - 1);
1246
- const endIdx = Math.min(totalLines, startIdx + limit);
1247
- const slice = allLines.slice(startIdx, endIdx);
1248
- const numbered = slice.map((line, i) => `${(startIdx + i + 1).toString()} ${line}`).join("\n");
1249
1421
  let content = numbered;
1250
- const contentLength = content.length;
1251
- if (contentLength > DEFAULT_MAX_RESULT_SIZE_CHARS) {
1422
+ if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
1252
1423
  content = content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) + `
1253
1424
 
1254
1425
  ... (\u8F93\u51FA\u8D85\u8FC7 ${DEFAULT_MAX_RESULT_SIZE_CHARS} \u5B57\u7B26\uFF0C\u5DF2\u622A\u65AD)`;
1255
1426
  }
1427
+ const startIdx = Math.max(0, offset - 1);
1428
+ const endIdx = Math.min(totalLines, startIdx + limit);
1256
1429
  if (endIdx < totalLines) {
1257
1430
  const nextOffset = endIdx + 1;
1431
+ const returnedLines = content.split("\n").filter((l) => l.trim()).length;
1258
1432
  content += `
1259
1433
 
1260
- ... (\u672C\u6B21\u8FD4\u56DE ${slice.length} \u884C / \u6587\u4EF6\u5171 ${totalLines} \u884C\uFF1B\u7528 offset=${nextOffset} \u7EE7\u7EED\u8BFB)`;
1434
+ ... (\u672C\u6B21\u8FD4\u56DE ${returnedLines} \u884C / \u6587\u4EF6\u5171 ${totalLines} \u884C\uFF1B\u7528 offset=${nextOffset} \u7EE7\u7EED\u8BFB)`;
1261
1435
  }
1262
1436
  if (st.size > 100 * 1024 && offset === 1) {
1263
1437
  content += `
@@ -1266,6 +1440,55 @@ async function call5(input) {
1266
1440
  }
1267
1441
  return { ok: true, content };
1268
1442
  }
1443
+ async function readSmallFile(filePath, offset, limit) {
1444
+ const raw = await readFile7(filePath, "utf8");
1445
+ if (raw.length === 0) {
1446
+ return { numbered: "", totalLines: 0, isEmpty: true };
1447
+ }
1448
+ const allLines = raw.split("\n");
1449
+ const totalLines = allLines.length;
1450
+ const startIdx = Math.max(0, offset - 1);
1451
+ const endIdx = Math.min(totalLines, startIdx + limit);
1452
+ const slice = allLines.slice(startIdx, endIdx);
1453
+ const numbered = slice.map((line, i) => `${(startIdx + i + 1).toString()} ${line}`).join("\n");
1454
+ return { numbered, totalLines, isEmpty: false };
1455
+ }
1456
+ async function readLargeFileStream(filePath, offset, limit) {
1457
+ return new Promise((resolvePromise, reject) => {
1458
+ const lines = [];
1459
+ let currentLine = 0;
1460
+ const startIdx = Math.max(0, offset - 1);
1461
+ const endLine = startIdx + limit;
1462
+ const input = createReadStream(filePath, { encoding: "utf8" });
1463
+ const rl = createInterface({
1464
+ input,
1465
+ crlfDelay: Infinity
1466
+ });
1467
+ input.on("error", (err) => {
1468
+ reject(err);
1469
+ });
1470
+ rl.on("line", (line) => {
1471
+ currentLine++;
1472
+ if (currentLine > endLine) {
1473
+ rl.close();
1474
+ rl.removeAllListeners();
1475
+ return;
1476
+ }
1477
+ if (currentLine >= offset) {
1478
+ lines.push(`${currentLine} ${line}`);
1479
+ }
1480
+ });
1481
+ rl.on("close", () => {
1482
+ resolvePromise({
1483
+ numbered: lines.join("\n"),
1484
+ totalLines: currentLine
1485
+ });
1486
+ });
1487
+ rl.on("error", (err) => {
1488
+ reject(err);
1489
+ });
1490
+ });
1491
+ }
1269
1492
  var readTool = {
1270
1493
  name: "Read",
1271
1494
  description: description5,
@@ -2081,7 +2304,7 @@ var webSearchTool = {
2081
2304
  // src/tools/write/write.ts
2082
2305
  import { existsSync as existsSync4 } from "fs";
2083
2306
  import { mkdir as mkdir5, stat as stat4, writeFile as writeFile4 } from "fs/promises";
2084
- import { dirname as dirname6, resolve as resolve9 } from "path";
2307
+ import { dirname as dirname6 } from "path";
2085
2308
  import { z as z9 } from "zod";
2086
2309
  var MAX_WRITE_SIZE_BYTES = 1024 * 1024 * 1024;
2087
2310
  var inputSchema9 = z9.object({
@@ -2096,19 +2319,10 @@ Usage:
2096
2319
  - If the parent directory does not exist, it will be created recursively.
2097
2320
  - ALWAYS prefer editing existing files in the codebase via the Edit tool. NEVER write new files unless explicitly required.
2098
2321
  - NEVER create documentation files (*.md) or README files unless explicitly requested by the User.`;
2099
- function validatePath2(filePath) {
2100
- if (filePath.includes("\0")) {
2101
- return { ok: false, error: "\u8DEF\u5F84\u5305\u542B\u975E\u6CD5\u5B57\u7B26\uFF08null byte\uFF09" };
2102
- }
2103
- if (process.platform === "win32" && filePath.includes("\\\\")) {
2104
- return { ok: false, error: "\u4E0D\u652F\u6301 UNC \u8DEF\u5F84\uFF08\\\\server\\share \u683C\u5F0F\uFF09\uFF0C\u8BF7\u4F7F\u7528\u672C\u5730\u8DEF\u5F84" };
2105
- }
2106
- return { ok: true };
2107
- }
2108
2322
  async function call9(input) {
2109
- const filePath = resolve9(input.file_path);
2110
- const pathCheck = validatePath2(filePath);
2111
- if (!pathCheck.ok) return pathCheck;
2323
+ const pathResult = validateAndResolvePath(input.file_path, getWorkingDir());
2324
+ if (!pathResult.ok) return pathResult;
2325
+ const filePath = pathResult.resolvedPath;
2112
2326
  const contentSize = Buffer.byteLength(input.content, "utf8");
2113
2327
  if (contentSize > MAX_WRITE_SIZE_BYTES) {
2114
2328
  return {
@@ -2127,9 +2341,14 @@ async function call9(input) {
2127
2341
  } catch {
2128
2342
  }
2129
2343
  }
2130
- await writeFile4(filePath, input.content, "utf8");
2344
+ let contentToWrite = input.content;
2345
+ if (fileExisted) {
2346
+ const lineEnding = await detectFileLineEndings(filePath);
2347
+ contentToWrite = applyLineEnding(input.content, lineEnding);
2348
+ }
2349
+ await writeFile4(filePath, contentToWrite, "utf8");
2131
2350
  const action = fileExisted ? "\u5DF2\u8986\u76D6" : "\u5DF2\u521B\u5EFA\u65B0\u6587\u4EF6";
2132
- const sizeInfo = fileExisted ? `\uFF08\u539F\u6587\u4EF6 ${originalSize} \u5B57\u7B26 \u2192 \u65B0\u5185\u5BB9 ${input.content.length} \u5B57\u7B26\uFF09` : `\uFF08${input.content.length} \u5B57\u7B26\uFF09`;
2351
+ const sizeInfo = fileExisted ? `\uFF08\u539F\u6587\u4EF6 ${originalSize} \u5B57\u7B26 \u2192 \u65B0\u5185\u5BB9 ${contentToWrite.length} \u5B57\u7B26\uFF09` : `\uFF08${contentToWrite.length} \u5B57\u7B26\uFF09`;
2133
2352
  return {
2134
2353
  ok: true,
2135
2354
  content: `${action} ${filePath}${sizeInfo}`
@@ -2962,7 +3181,7 @@ function MessageRow({ message }) {
2962
3181
 
2963
3182
  // src/ui/StatusLine.tsx
2964
3183
  import { Box as Box4, Text as Text4 } from "ink";
2965
- import { homedir as homedir4 } from "os";
3184
+ import { homedir as homedir5 } from "os";
2966
3185
  import { sep } from "path";
2967
3186
 
2968
3187
  // src/llm/client.ts
@@ -3350,13 +3569,26 @@ function useTokenUsage(messages, provider) {
3350
3569
  }
3351
3570
 
3352
3571
  // src/ui/StatusLine.tsx
3353
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3354
- function StatusLine({ provider, history }) {
3572
+ import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3573
+ function StatusLine({ provider, history, pluginLoop }) {
3355
3574
  const usage = useTokenUsage(history, provider);
3356
3575
  const ratio = usage.tokens / usage.threshold;
3357
3576
  const color = ratio >= 1 ? "red" : ratio >= 0.7 ? "yellow" : "green";
3358
3577
  const cwdDisplay = shortenPath(getWorkingDir());
3359
3578
  return /* @__PURE__ */ jsxs4(Box4, { children: [
3579
+ pluginLoop && /* @__PURE__ */ jsxs4(Fragment, { children: [
3580
+ /* @__PURE__ */ jsxs4(Text4, { color: "magenta", children: [
3581
+ "\u{1F504} ",
3582
+ pluginLoop.pluginName,
3583
+ " "
3584
+ ] }),
3585
+ /* @__PURE__ */ jsxs4(Text4, { color: "magenta", children: [
3586
+ pluginLoop.current,
3587
+ "/",
3588
+ pluginLoop.max
3589
+ ] }),
3590
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: " \xB7 " })
3591
+ ] }),
3360
3592
  /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "cwd " }),
3361
3593
  /* @__PURE__ */ jsx4(Text4, { color: "gray", children: cwdDisplay }),
3362
3594
  /* @__PURE__ */ jsx4(Text4, { color: "gray", children: " \xB7 " }),
@@ -3382,7 +3614,7 @@ function fmt(n) {
3382
3614
  return `${(n / 1e6).toFixed(2)}M`;
3383
3615
  }
3384
3616
  function shortenPath(abs) {
3385
- const home = homedir4();
3617
+ const home = homedir5();
3386
3618
  let p = abs;
3387
3619
  if (home && (p === home || p.startsWith(home + sep))) {
3388
3620
  p = "~" + p.slice(home.length);
@@ -3480,9 +3712,12 @@ function findHeadCutpoint(messages, proposedCut) {
3480
3712
  }
3481
3713
 
3482
3714
  // src/context/reactiveCompact.ts
3483
- var attemptedThisSession = false;
3484
- function resetReactiveCompactState() {
3485
- attemptedThisSession = false;
3715
+ function createReactiveCompactState() {
3716
+ return { attempted: false };
3717
+ }
3718
+ var defaultState = createReactiveCompactState();
3719
+ function resetReactiveCompactState(state = defaultState) {
3720
+ state.attempted = false;
3486
3721
  }
3487
3722
  function isPromptTooLongError(error) {
3488
3723
  const msg = errorMessage(error).toLowerCase();
@@ -3499,18 +3734,18 @@ function errorMessage(error) {
3499
3734
  }
3500
3735
  return String(error ?? "");
3501
3736
  }
3502
- async function reactiveCompactIfApplicable(messages, provider, error) {
3737
+ async function reactiveCompactIfApplicable(messages, provider, error, state = defaultState) {
3503
3738
  if (!isPromptTooLongError(error)) {
3504
3739
  return { recovered: false, messages, reason: "not a prompt-too-long error" };
3505
3740
  }
3506
- if (attemptedThisSession) {
3741
+ if (state.attempted) {
3507
3742
  return {
3508
3743
  recovered: false,
3509
3744
  messages,
3510
3745
  reason: "already attempted this session \u2014 use /new or /compact manually"
3511
3746
  };
3512
3747
  }
3513
- attemptedThisSession = true;
3748
+ state.attempted = true;
3514
3749
  try {
3515
3750
  const r = await forceCompact(messages, provider);
3516
3751
  return {
@@ -3544,6 +3779,688 @@ async function reactiveCompactIfApplicable(messages, provider, error) {
3544
3779
  };
3545
3780
  }
3546
3781
 
3782
+ // src/plugins/commandRouter.ts
3783
+ import { readFile as readFile8, readdir as readdir3 } from "fs/promises";
3784
+ import { join as join6 } from "path";
3785
+ var PLUGINS_DIR = join6(findPackageRoot(import.meta.url), "plugins");
3786
+ var pluginCache = /* @__PURE__ */ new Map();
3787
+ var discoveryDone = false;
3788
+ function stripQuotes2(s) {
3789
+ const trimmed = s.trim();
3790
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
3791
+ return trimmed.slice(1, -1);
3792
+ }
3793
+ return trimmed;
3794
+ }
3795
+ function parseMarkdownFrontmatter(content) {
3796
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
3797
+ if (!match) return {};
3798
+ const frontmatter = {};
3799
+ for (const line of match[1].split("\n")) {
3800
+ const colon = line.indexOf(":");
3801
+ if (colon < 0) continue;
3802
+ const key = line.slice(0, colon).trim();
3803
+ const value = line.slice(colon + 1).trim();
3804
+ if (key) frontmatter[key] = stripQuotes2(value);
3805
+ }
3806
+ return frontmatter;
3807
+ }
3808
+ async function loadPlugin(pluginDirPath) {
3809
+ const dirName = pluginDirPath.split("/").pop() ?? pluginDirPath;
3810
+ const manifestPath = join6(pluginDirPath, ".claude-plugin", "plugin.json");
3811
+ let manifestName = dirName;
3812
+ let manifestVersion;
3813
+ let manifestDesc;
3814
+ try {
3815
+ const raw = await readFile8(manifestPath, "utf8");
3816
+ const parsed = JSON.parse(raw);
3817
+ manifestName = parsed.name ?? dirName;
3818
+ manifestVersion = parsed.version;
3819
+ manifestDesc = parsed.description;
3820
+ } catch {
3821
+ }
3822
+ const commandsDir = join6(pluginDirPath, "commands");
3823
+ const commands = [];
3824
+ try {
3825
+ const entries = await readdir3(commandsDir, { withFileTypes: true });
3826
+ for (const entry of entries) {
3827
+ if (!entry.name.endsWith(".md")) continue;
3828
+ const cmdPath = join6(commandsDir, entry.name);
3829
+ try {
3830
+ const content = await readFile8(cmdPath, "utf8");
3831
+ const fm = parseMarkdownFrontmatter(content);
3832
+ const sep2 = content.indexOf("\n---", 4);
3833
+ const body = sep2 >= 0 ? content.slice(sep2 + 4).trim() : content.trim();
3834
+ commands.push({
3835
+ name: entry.name.replace(/\.md$/, ""),
3836
+ description: fm.description ?? "(no description)",
3837
+ argumentHint: fm["argument-hint"],
3838
+ pluginName: manifestName,
3839
+ pluginRoot: pluginDirPath,
3840
+ promptBody: body
3841
+ });
3842
+ } catch {
3843
+ }
3844
+ }
3845
+ } catch {
3846
+ }
3847
+ const hooksJsonPath = join6(pluginDirPath, "hooks", "hooks.json");
3848
+ let hasStopHook = false;
3849
+ try {
3850
+ const hooksRaw = await readFile8(hooksJsonPath, "utf8");
3851
+ const hooksParsed = JSON.parse(hooksRaw);
3852
+ const stopHooks = hooksParsed?.hooks?.Stop;
3853
+ if (Array.isArray(stopHooks) && stopHooks.length > 0) {
3854
+ hasStopHook = true;
3855
+ }
3856
+ } catch {
3857
+ }
3858
+ if (commands.length === 0 && !hasStopHook) return null;
3859
+ return {
3860
+ name: manifestName,
3861
+ version: manifestVersion,
3862
+ description: manifestDesc,
3863
+ root: pluginDirPath,
3864
+ commands,
3865
+ hasStopHook
3866
+ };
3867
+ }
3868
+ async function discoverPlugins() {
3869
+ if (discoveryDone && pluginCache.size > 0) {
3870
+ return Array.from(pluginCache.values());
3871
+ }
3872
+ pluginCache.clear();
3873
+ discoveryDone = true;
3874
+ try {
3875
+ const entries = await readdir3(PLUGINS_DIR, { withFileTypes: true });
3876
+ for (const entry of entries) {
3877
+ if (!entry.isDirectory()) continue;
3878
+ if (entry.name.startsWith(".")) continue;
3879
+ const plugin = await loadPlugin(join6(PLUGINS_DIR, entry.name));
3880
+ if (plugin) {
3881
+ pluginCache.set(plugin.name, plugin);
3882
+ }
3883
+ }
3884
+ } catch {
3885
+ }
3886
+ return Array.from(pluginCache.values());
3887
+ }
3888
+ function resolveCommand(input) {
3889
+ const trimmed = input.trimStart();
3890
+ if (!trimmed.startsWith("/")) return null;
3891
+ const spaceIdx = trimmed.indexOf(" ", 1);
3892
+ const cmdName = spaceIdx >= 0 ? trimmed.slice(1, spaceIdx) : trimmed.slice(1);
3893
+ const args = spaceIdx >= 0 ? trimmed.slice(spaceIdx + 1) : "";
3894
+ for (const plugin of pluginCache.values()) {
3895
+ for (const cmd of plugin.commands) {
3896
+ if (cmd.name === cmdName) {
3897
+ return { cmd, arguments: args };
3898
+ }
3899
+ }
3900
+ }
3901
+ return null;
3902
+ }
3903
+ var COMMAND_DEFAULTS = {
3904
+ "ralph-loop": ["--max-iterations", "50"]
3905
+ };
3906
+ function applyDefaultArgs(cmdName, args) {
3907
+ const defaults = COMMAND_DEFAULTS[cmdName];
3908
+ if (!defaults) return args;
3909
+ const existing = new Set(args.trim().split(/\s+/).filter(Boolean));
3910
+ let result = args;
3911
+ for (let i = 0; i < defaults.length; i += 2) {
3912
+ const flag = defaults[i];
3913
+ if (existing.has(flag)) continue;
3914
+ result = result.trim() ? `${result} ${flag} ${defaults[i + 1]}` : `${flag} ${defaults[i + 1]}`;
3915
+ }
3916
+ return result;
3917
+ }
3918
+ function buildCommandInput(resolved) {
3919
+ const { cmd, arguments: rawArgs } = resolved;
3920
+ const args = applyDefaultArgs(cmd.name, rawArgs);
3921
+ let input = cmd.promptBody;
3922
+ input = input.replaceAll("${CLAUDE_PLUGIN_ROOT}", cmd.pluginRoot);
3923
+ input = input.replaceAll("$ARGUMENTS", args);
3924
+ input = input.replaceAll("${ARGUMENTS}", args);
3925
+ if (args.trim()) {
3926
+ input += `
3927
+
3928
+ \u7528\u6237\u53C2\u6570: ${args.trim()}`;
3929
+ }
3930
+ return input;
3931
+ }
3932
+ function getActiveStopHookPlugins() {
3933
+ const result = [];
3934
+ for (const plugin of pluginCache.values()) {
3935
+ if (plugin.hasStopHook) {
3936
+ result.push(plugin.root);
3937
+ }
3938
+ }
3939
+ return result;
3940
+ }
3941
+
3942
+ // src/plugins/stopHook.ts
3943
+ import { readFile as readFile9 } from "fs/promises";
3944
+ import { join as join7 } from "path";
3945
+ import { spawn as spawn4 } from "child_process";
3946
+ async function loadStopHookConfig(pluginRoot) {
3947
+ const hooksJsonPath = join7(pluginRoot, "hooks", "hooks.json");
3948
+ try {
3949
+ const raw = await readFile9(hooksJsonPath, "utf8");
3950
+ const parsed = JSON.parse(raw);
3951
+ const stopEntries = parsed?.hooks?.Stop;
3952
+ if (!Array.isArray(stopEntries)) return [];
3953
+ const commands = [];
3954
+ for (const entry of stopEntries) {
3955
+ const hooks = entry.hooks;
3956
+ if (Array.isArray(hooks)) {
3957
+ for (const h of hooks) {
3958
+ if (h.type === "command" && h.command) {
3959
+ commands.push(h);
3960
+ }
3961
+ }
3962
+ }
3963
+ }
3964
+ return commands;
3965
+ } catch {
3966
+ return [];
3967
+ }
3968
+ }
3969
+ async function executeStopHooks(pluginRoots, transcriptText) {
3970
+ if (process.platform === "win32") {
3971
+ return { decision: "pass" };
3972
+ }
3973
+ for (const pluginRoot of pluginRoots) {
3974
+ const hookConfigs = await loadStopHookConfig(pluginRoot);
3975
+ for (const hookConfig of hookConfigs) {
3976
+ const result = await runSingleStopHook(hookConfig, pluginRoot, transcriptText);
3977
+ if (result.decision === "block") {
3978
+ return result;
3979
+ }
3980
+ }
3981
+ }
3982
+ return { decision: "pass" };
3983
+ }
3984
+ function runSingleStopHook(hookConfig, pluginRoot, transcriptText) {
3985
+ const resolvedCommand = hookConfig.command.replaceAll("${CLAUDE_PLUGIN_ROOT}", pluginRoot);
3986
+ return new Promise((resolve9) => {
3987
+ const child = spawn4("bash", [resolvedCommand], {
3988
+ env: {
3989
+ ...process.env,
3990
+ CLAUDE_PLUGIN_ROOT: pluginRoot
3991
+ }
3992
+ });
3993
+ let stdout = "";
3994
+ child.stdout.on("data", (data) => {
3995
+ stdout += data.toString();
3996
+ });
3997
+ child.on("error", () => {
3998
+ resolve9({ decision: "pass" });
3999
+ });
4000
+ child.on("close", (code) => {
4001
+ if (code !== 0) {
4002
+ resolve9({ decision: "pass" });
4003
+ return;
4004
+ }
4005
+ const trimmed = stdout.trim();
4006
+ if (!trimmed) {
4007
+ resolve9({ decision: "pass" });
4008
+ return;
4009
+ }
4010
+ try {
4011
+ const parsed = JSON.parse(trimmed);
4012
+ if (parsed.decision === "block") {
4013
+ resolve9({
4014
+ decision: "block",
4015
+ reason: typeof parsed.reason === "string" ? parsed.reason : void 0,
4016
+ systemMessage: typeof parsed.systemMessage === "string" ? parsed.systemMessage : void 0
4017
+ });
4018
+ return;
4019
+ }
4020
+ resolve9({ decision: "pass" });
4021
+ } catch {
4022
+ resolve9({ decision: "pass" });
4023
+ }
4024
+ });
4025
+ child.stdin.write(transcriptText);
4026
+ child.stdin.end();
4027
+ });
4028
+ }
4029
+
4030
+ // src/plugins/verificationGate.ts
4031
+ import { existsSync as existsSync5, readFileSync } from "fs";
4032
+ import { spawn as spawn5 } from "child_process";
4033
+ function parseVerifyArg(arg) {
4034
+ const colonIdx = arg.indexOf(":");
4035
+ if (colonIdx < 0) return null;
4036
+ const type = arg.slice(0, colonIdx).trim().toLowerCase();
4037
+ const value = arg.slice(colonIdx + 1).trim();
4038
+ switch (type) {
4039
+ case "shell":
4040
+ return { type: "shell", command: value, timeout: 3e4 };
4041
+ case "file_exists":
4042
+ return { type: "file_exists", file: value };
4043
+ case "file_contains": {
4044
+ const sep2 = value.indexOf(":");
4045
+ if (sep2 < 0) return null;
4046
+ return {
4047
+ type: "file_contains",
4048
+ file: value.slice(0, sep2),
4049
+ pattern: value.slice(sep2 + 1)
4050
+ };
4051
+ }
4052
+ case "test_count": {
4053
+ const count = parseInt(value, 10);
4054
+ if (isNaN(count) || count < 0) return null;
4055
+ return { type: "test_count", minCount: count };
4056
+ }
4057
+ default:
4058
+ return null;
4059
+ }
4060
+ }
4061
+ function parseVerifyArgs(args) {
4062
+ const checks = [];
4063
+ const regex = /--verify\s+("[^"]*"|\S+)/gi;
4064
+ let match;
4065
+ while ((match = regex.exec(args)) !== null) {
4066
+ const raw = match[1].replace(/^"|"$/g, "");
4067
+ const check = parseVerifyArg(raw);
4068
+ if (check) checks.push(check);
4069
+ }
4070
+ return checks;
4071
+ }
4072
+ function runShell(command, timeout) {
4073
+ return new Promise((resolve9) => {
4074
+ const isWin = process.platform === "win32";
4075
+ const child = isWin ? spawn5("cmd", ["/c", command], { timeout, env: process.env }) : spawn5("bash", ["-c", command], { timeout, env: process.env });
4076
+ let stdout = "";
4077
+ let stderr = "";
4078
+ child.stdout.on("data", (d) => {
4079
+ stdout += d.toString();
4080
+ });
4081
+ child.stderr.on("data", (d) => {
4082
+ stderr += d.toString();
4083
+ });
4084
+ child.on("error", () => {
4085
+ resolve9({ exitCode: null, stdout, stderr, errored: true });
4086
+ });
4087
+ child.on("close", (code) => {
4088
+ resolve9({ exitCode: code, stdout, stderr, errored: false });
4089
+ });
4090
+ });
4091
+ }
4092
+ async function verifyShell(command, timeout) {
4093
+ const r = await runShell(command, timeout);
4094
+ if (r.errored) {
4095
+ return {
4096
+ check: { type: "shell", command },
4097
+ passed: false,
4098
+ output: `\u6267\u884C\u5931\u8D25`
4099
+ };
4100
+ }
4101
+ return {
4102
+ check: { type: "shell", command },
4103
+ passed: r.exitCode === 0,
4104
+ output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`
4105
+ };
4106
+ }
4107
+ function verifyFileExists(file) {
4108
+ const exists = existsSync5(file);
4109
+ return {
4110
+ check: { type: "file_exists", file },
4111
+ passed: exists,
4112
+ output: exists ? "\u6587\u4EF6\u5B58\u5728" : `\u6587\u4EF6\u4E0D\u5B58\u5728: ${file}`
4113
+ };
4114
+ }
4115
+ function verifyFileContains(file, pattern) {
4116
+ try {
4117
+ const content = readFileSync(file, "utf8");
4118
+ const regex = new RegExp(pattern);
4119
+ const matched = regex.test(content);
4120
+ return {
4121
+ check: { type: "file_contains", file, pattern },
4122
+ passed: matched,
4123
+ output: matched ? `\u6587\u4EF6\u5305\u542B "${pattern}"` : `\u6587\u4EF6\u4E0D\u5305\u542B "${pattern}"`
4124
+ };
4125
+ } catch {
4126
+ return {
4127
+ check: { type: "file_contains", file, pattern },
4128
+ passed: false,
4129
+ output: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${file}`
4130
+ };
4131
+ }
4132
+ }
4133
+ async function verifyTestCount(minCount) {
4134
+ const r = await runShell(`bun test`, 6e4);
4135
+ const combined = `${r.stdout}
4136
+ ${r.stderr}`;
4137
+ if (r.errored) {
4138
+ return {
4139
+ check: { type: "test_count", minCount },
4140
+ passed: false,
4141
+ output: "\u65E0\u6CD5\u6267\u884C bun test"
4142
+ };
4143
+ }
4144
+ const matches = [...combined.matchAll(/(\d+)\s+pass/gi)];
4145
+ const count = matches.length > 0 ? parseInt(matches[matches.length - 1][1], 10) : 0;
4146
+ const passed = count >= minCount;
4147
+ return {
4148
+ check: { type: "test_count", minCount },
4149
+ passed,
4150
+ output: `${count} pass (\u9700\u8981 >=${minCount})`
4151
+ };
4152
+ }
4153
+ async function runVerification(checks) {
4154
+ if (checks.length === 0) {
4155
+ return { passed: true, details: [], summary: "\u65E0\u9A8C\u8BC1\u9879" };
4156
+ }
4157
+ const results = [];
4158
+ for (const check of checks) {
4159
+ let result;
4160
+ switch (check.type) {
4161
+ case "shell":
4162
+ result = await verifyShell(check.command ?? "", check.timeout ?? 3e4);
4163
+ break;
4164
+ case "file_exists":
4165
+ result = verifyFileExists(check.file ?? "");
4166
+ break;
4167
+ case "file_contains":
4168
+ result = verifyFileContains(check.file ?? "", check.pattern ?? "");
4169
+ break;
4170
+ case "test_count":
4171
+ result = await verifyTestCount(check.minCount ?? 0);
4172
+ break;
4173
+ default:
4174
+ result = {
4175
+ check,
4176
+ passed: false,
4177
+ output: `\u672A\u77E5\u9A8C\u8BC1\u7C7B\u578B: ${check.type}`
4178
+ };
4179
+ }
4180
+ results.push(result);
4181
+ }
4182
+ const allPassed = results.every((r) => r.passed);
4183
+ const failedNames = results.filter((r) => !r.passed).map((r) => formatCheckName(r.check));
4184
+ let summary;
4185
+ if (allPassed) {
4186
+ summary = `\u2705 \u5168\u90E8\u901A\u8FC7 (${results.length}/${results.length})`;
4187
+ } else {
4188
+ summary = `\u274C \u9A8C\u8BC1\u672A\u901A\u8FC7: ${failedNames.join(", ")}`;
4189
+ }
4190
+ return { passed: allPassed, details: results, summary };
4191
+ }
4192
+ function formatCheckName(check) {
4193
+ switch (check.type) {
4194
+ case "shell":
4195
+ return `shell(${(check.command ?? "").slice(0, 40)})`;
4196
+ case "file_exists":
4197
+ return `exists(${check.file})`;
4198
+ case "file_contains":
4199
+ return `contains(${check.file}:${check.pattern})`;
4200
+ case "test_count":
4201
+ return `tests(>=${check.minCount})`;
4202
+ default:
4203
+ return check.type;
4204
+ }
4205
+ }
4206
+
4207
+ // src/plugins/goalState.ts
4208
+ import { mkdir as mkdir6, appendFile, writeFile as writeFile5, unlink as unlink2, rmdir as rmdir2 } from "fs/promises";
4209
+ import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
4210
+ import { join as join8 } from "path";
4211
+ var Phase = /* @__PURE__ */ ((Phase2) => {
4212
+ Phase2["PLAN"] = "plan";
4213
+ Phase2["BUILD"] = "build";
4214
+ Phase2["VERIFY"] = "verify";
4215
+ Phase2["HEAL"] = "heal";
4216
+ Phase2["DONE"] = "done";
4217
+ return Phase2;
4218
+ })(Phase || {});
4219
+ var VALID_PHASES = new Set(Object.values(Phase));
4220
+ var PHASE_TRANSITIONS = {
4221
+ ["plan" /* PLAN */]: {
4222
+ plan_complete: "build" /* BUILD */,
4223
+ stuck: "plan" /* PLAN */
4224
+ },
4225
+ ["build" /* BUILD */]: {
4226
+ task_complete: "verify" /* VERIFY */,
4227
+ need_replan: "plan" /* PLAN */,
4228
+ tests_failing: "heal" /* HEAL */
4229
+ },
4230
+ ["verify" /* VERIFY */]: {
4231
+ all_pass: "build" /* BUILD */,
4232
+ failures: "heal" /* HEAL */,
4233
+ goal_complete: "done" /* DONE */
4234
+ },
4235
+ ["heal" /* HEAL */]: {
4236
+ fixed: "verify" /* VERIFY */,
4237
+ cannot_fix_locally: "plan" /* PLAN */
4238
+ },
4239
+ ["done" /* DONE */]: {}
4240
+ };
4241
+ function isLegalTransition(from, to) {
4242
+ if (from === to) return true;
4243
+ const allowed = PHASE_TRANSITIONS[from];
4244
+ if (!allowed) return false;
4245
+ return Object.values(allowed).includes(to);
4246
+ }
4247
+ var LEARNINGS_TAIL_LINES = 20;
4248
+ var GoalState = class {
4249
+ dir;
4250
+ constructor(workspaceDir, sessionTag) {
4251
+ const suffix = sessionTag ? `-${sessionTag}` : "";
4252
+ this.dir = join8(workspaceDir, `.minimal-agent${suffix}`);
4253
+ }
4254
+ async reset() {
4255
+ const files = ["goal.md", "completion.md", "phase.md", "progress.md", "learnings.md", "decisions.md"];
4256
+ for (const f of files) {
4257
+ try {
4258
+ await unlink2(join8(this.dir, f));
4259
+ } catch {
4260
+ }
4261
+ }
4262
+ }
4263
+ async init(goal, criteria) {
4264
+ await mkdir6(this.dir, { recursive: true });
4265
+ const files = {
4266
+ goal: `# \u4E0D\u53EF\u53D8\u76EE\u6807 (IMMUTABLE GOAL)
4267
+
4268
+ ${goal}
4269
+ `,
4270
+ completion: JSON.stringify(criteria, null, 2),
4271
+ phase: "plan" /* PLAN */,
4272
+ progress: "",
4273
+ learnings: "",
4274
+ decisions: ""
4275
+ };
4276
+ for (const [name, content] of Object.entries(files)) {
4277
+ const path2 = join8(this.dir, `${name}.md`);
4278
+ if (!existsSync6(path2)) {
4279
+ await writeFile5(path2, content);
4280
+ }
4281
+ }
4282
+ }
4283
+ get goal() {
4284
+ try {
4285
+ return readFileSync2(join8(this.dir, "goal.md"), "utf8").trim();
4286
+ } catch {
4287
+ return "";
4288
+ }
4289
+ }
4290
+ get completionCriteria() {
4291
+ try {
4292
+ const raw = readFileSync2(join8(this.dir, "completion.md"), "utf8").trim();
4293
+ return JSON.parse(raw);
4294
+ } catch {
4295
+ return [];
4296
+ }
4297
+ }
4298
+ get currentPhase() {
4299
+ try {
4300
+ const raw = readFileSync2(join8(this.dir, "phase.md"), "utf8").trim();
4301
+ if (VALID_PHASES.has(raw)) {
4302
+ return raw;
4303
+ }
4304
+ } catch {
4305
+ }
4306
+ return "plan" /* PLAN */;
4307
+ }
4308
+ /**
4309
+ * 切换阶段。`reason` 是给人看的日志文本,不参与校验。
4310
+ * 校验只看 from→to 是否在 PHASE_TRANSITIONS 表里有路径(任意 event 通向 to 即合法)。
4311
+ * 想绕开 FSM 用 forceSetPhase。
4312
+ */
4313
+ async setPhase(phase, reason) {
4314
+ if (!VALID_PHASES.has(phase)) {
4315
+ throw new Error(`Invalid phase: ${phase}`);
4316
+ }
4317
+ const current = this.currentPhase;
4318
+ if (!isLegalTransition(current, phase)) {
4319
+ throw new Error(
4320
+ `\u975E\u6CD5\u9636\u6BB5\u5207\u6362: ${current} \u2192 ${phase}\u3002\u8BE5\u76EE\u6807\u9636\u6BB5\u4E0D\u5728 PHASE_TRANSITIONS[${current}] \u7684\u53EF\u8FBE\u96C6\u5408\u5185\uFF0C\u9700\u8981\u8D70 forceSetPhase\u3002`
4321
+ );
4322
+ }
4323
+ await writeFile5(join8(this.dir, "phase.md"), phase);
4324
+ await this.appendProgress(`PHASE \u2192 ${phase}: ${reason}`);
4325
+ }
4326
+ async forceSetPhase(phase, reason) {
4327
+ if (!VALID_PHASES.has(phase)) {
4328
+ throw new Error(`Invalid phase: ${phase}`);
4329
+ }
4330
+ await writeFile5(join8(this.dir, "phase.md"), phase);
4331
+ await this.appendProgress(`PHASE \u2192 ${phase}: ${reason}`);
4332
+ }
4333
+ async appendProgress(line) {
4334
+ const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
4335
+ await appendFile(join8(this.dir, "progress.md"), `[${timestamp}] ${line}
4336
+ `);
4337
+ }
4338
+ tailProgress(n) {
4339
+ try {
4340
+ const content = readFileSync2(join8(this.dir, "progress.md"), "utf8");
4341
+ const lines = content.trim().split("\n").filter(Boolean);
4342
+ return lines.slice(-n).join("\n");
4343
+ } catch {
4344
+ return "";
4345
+ }
4346
+ }
4347
+ async appendLearning(lesson) {
4348
+ await appendFile(join8(this.dir, "learnings.md"), `- ${lesson}
4349
+ `);
4350
+ }
4351
+ get learnings() {
4352
+ try {
4353
+ const raw = readFileSync2(join8(this.dir, "learnings.md"), "utf8").trim();
4354
+ if (!raw) return "";
4355
+ const lines = raw.split("\n").filter(Boolean);
4356
+ return lines.slice(-LEARNINGS_TAIL_LINES).join("\n");
4357
+ } catch {
4358
+ return "";
4359
+ }
4360
+ }
4361
+ async recordDecision(ctx, options, chosen, reasoning) {
4362
+ const entry = {
4363
+ iteration: ctx.iteration,
4364
+ phase: ctx.phase,
4365
+ contextSummary: ctx.summary,
4366
+ options,
4367
+ chosen,
4368
+ reasoning
4369
+ };
4370
+ const line = JSON.stringify(entry);
4371
+ await appendFile(join8(this.dir, "decisions.md"), `${line}
4372
+ `);
4373
+ }
4374
+ findSimilarDecisions(ctx, k = 3) {
4375
+ try {
4376
+ const content = readFileSync2(join8(this.dir, "decisions.md"), "utf8");
4377
+ const lines = content.trim().split("\n").filter(Boolean);
4378
+ const entries = [];
4379
+ for (const line of lines) {
4380
+ try {
4381
+ entries.push(JSON.parse(line));
4382
+ } catch {
4383
+ }
4384
+ }
4385
+ const scored = entries.map((entry) => ({
4386
+ entry,
4387
+ score: this._similarity(entry.contextSummary, ctx.summary)
4388
+ })).sort((a, b) => b.score - a.score);
4389
+ return scored.slice(0, k).filter((s) => s.score > 0.3).map((s) => s.entry);
4390
+ } catch {
4391
+ return [];
4392
+ }
4393
+ }
4394
+ summarizeDecisions(maxEntries = 5) {
4395
+ try {
4396
+ const content = readFileSync2(join8(this.dir, "decisions.md"), "utf8");
4397
+ const lines = content.trim().split("\n").filter(Boolean);
4398
+ const entries = [];
4399
+ for (const line of lines.slice(-maxEntries * 2)) {
4400
+ try {
4401
+ entries.push(JSON.parse(line));
4402
+ } catch {
4403
+ }
4404
+ }
4405
+ return entries.slice(-maxEntries).map(
4406
+ (e) => `\u8FED\u4EE3 ${e.iteration}\uFF08${e.phase}\uFF09: \u5728 [${e.options.join(", ")}] \u4E2D\u9009\u62E9\u4E86 **${e.chosen}**\uFF0C\u539F\u56E0\uFF1A${e.reasoning.slice(0, 80)}`
4407
+ ).join("\n");
4408
+ } catch {
4409
+ return "(\u65E0\u51B3\u7B56\u8BB0\u5F55)";
4410
+ }
4411
+ }
4412
+ composeContext(iteration) {
4413
+ const sections = [];
4414
+ sections.push(`# \u4E0D\u53EF\u53D8\u76EE\u6807 (IMMUTABLE GOAL)
4415
+ ${this.goal}
4416
+ \u26A0\uFE0F \u6CE8\u610F\uFF1A\u4F60\u4E0D\u80FD\u4FEE\u6539\u6216\u6269\u5927\u4E0A\u8FF0\u76EE\u6807\u3002\u5982\u679C\u4F60\u8BA4\u4E3A\u76EE\u6807\u672C\u8EAB\u6709\u95EE\u9898\uFF0C\u8BF7\u8F93\u51FA <PROMISE>NEED_GOAL_REVISION</PROMISE> \u5E76\u505C\u6B62\u3002`);
4417
+ sections.push(`# \u5F53\u524D\u9636\u6BB5: ${this.currentPhase.toUpperCase()}`);
4418
+ const lrn = this.learnings;
4419
+ if (lrn) {
4420
+ sections.push(`# \u5173\u952E\u6559\u8BAD\uFF08\u5FC5\u987B\u9075\u5B88\uFF0C\u907F\u514D\u91CD\u590D\u8E29\u5751\uFF09
4421
+ ${lrn}`);
4422
+ }
4423
+ const decisions = this.summarizeDecisions(3);
4424
+ if (decisions !== "(\u65E0\u51B3\u7B56\u8BB0\u5F55)") {
4425
+ sections.push(`# \u5386\u53F2\u5173\u952E\u51B3\u7B56\uFF08\u8BF7\u53C2\u8003\uFF0C\u907F\u514D\u91CD\u590D\u8BD5\u9519\uFF09
4426
+ ${decisions}`);
4427
+ }
4428
+ const recentProgress = this.tailProgress(10);
4429
+ if (recentProgress) {
4430
+ sections.push(`# \u6700\u8FD1\u8FDB\u5EA6
4431
+ ${recentProgress}`);
4432
+ }
4433
+ sections.push(`---
4434
+
4435
+ # \u672C\u8F6E\u4EFB\u52A1 (\u8FED\u4EE3 ${iteration})`);
4436
+ return sections.join("\n\n---\n\n");
4437
+ }
4438
+ async cleanup() {
4439
+ const files = ["goal.md", "completion.md", "phase.md", "progress.md", "learnings.md", "decisions.md"];
4440
+ for (const f of files) {
4441
+ try {
4442
+ await unlink2(join8(this.dir, f));
4443
+ } catch {
4444
+ }
4445
+ }
4446
+ try {
4447
+ await rmdir2(this.dir);
4448
+ } catch {
4449
+ }
4450
+ }
4451
+ _similarity(a, b) {
4452
+ if (!a || !b) return 0;
4453
+ const wordsA = new Set(a.toLowerCase().split(/\s+/));
4454
+ const wordsB = new Set(b.toLowerCase().split(/\s+/));
4455
+ let intersection = 0;
4456
+ for (const word of wordsA) {
4457
+ if (wordsB.has(word)) intersection++;
4458
+ }
4459
+ const union = (/* @__PURE__ */ new Set([...wordsA, ...wordsB])).size;
4460
+ return union > 0 ? intersection / union : 0;
4461
+ }
4462
+ };
4463
+
3547
4464
  // src/context/microCompactLite.ts
3548
4465
  import { createHash as createHash2 } from "crypto";
3549
4466
  var MAX_REPEAT_COUNT = 3;
@@ -3552,10 +4469,12 @@ var HEAD_KEEP_CHARS = 2e3;
3552
4469
  var TAIL_KEEP_CHARS = 1e3;
3553
4470
  var MAX_KEEP_ROUNDS = 10;
3554
4471
  var SHORT_CONTENT_THRESHOLD = 200;
3555
- var currentTurn = 0;
3556
- var contentCache = /* @__PURE__ */ new Map();
3557
- function incrementTurn() {
3558
- currentTurn++;
4472
+ function createMicroCompactState() {
4473
+ return { turn: 0, cache: /* @__PURE__ */ new Map() };
4474
+ }
4475
+ var defaultState2 = createMicroCompactState();
4476
+ function incrementTurn(state = defaultState2) {
4477
+ state.turn++;
3559
4478
  }
3560
4479
  var COMPRESSIBLE_TOOLS = /* @__PURE__ */ new Set([
3561
4480
  "Grep",
@@ -3563,7 +4482,7 @@ var COMPRESSIBLE_TOOLS = /* @__PURE__ */ new Set([
3563
4482
  "WebFetch",
3564
4483
  "Glob"
3565
4484
  ]);
3566
- function microCompact(toolName, content) {
4485
+ function microCompact(toolName, content, state = defaultState2) {
3567
4486
  if (!content) return content;
3568
4487
  if (content.startsWith("Error:") || content.startsWith("\u9519\u8BEF")) {
3569
4488
  return content;
@@ -3572,7 +4491,7 @@ function microCompact(toolName, content) {
3572
4491
  return content;
3573
4492
  }
3574
4493
  const hash = sha1(content);
3575
- const existing = contentCache.get(hash);
4494
+ const existing = state.cache.get(hash);
3576
4495
  if (existing) {
3577
4496
  existing.count++;
3578
4497
  if (existing.count > MAX_REPEAT_COUNT) {
@@ -3580,17 +4499,17 @@ function microCompact(toolName, content) {
3580
4499
  }
3581
4500
  return content;
3582
4501
  }
3583
- contentCache.set(hash, { count: 1, firstToolName: toolName, firstSeenTurn: currentTurn });
4502
+ state.cache.set(hash, { count: 1, firstToolName: toolName, firstSeenTurn: state.turn });
3584
4503
  if (content.length > MAX_RESULT_SIZE && COMPRESSIBLE_TOOLS.has(toolName)) {
3585
4504
  return truncateContent(content);
3586
4505
  }
3587
4506
  return content;
3588
4507
  }
3589
- function expireOldEntries() {
4508
+ function expireOldEntries(state = defaultState2) {
3590
4509
  let expired = 0;
3591
- for (const [hash, entry] of contentCache) {
3592
- if (currentTurn - entry.firstSeenTurn > MAX_KEEP_ROUNDS) {
3593
- contentCache.delete(hash);
4510
+ for (const [hash, entry] of state.cache) {
4511
+ if (state.turn - entry.firstSeenTurn > MAX_KEEP_ROUNDS) {
4512
+ state.cache.delete(hash);
3594
4513
  expired++;
3595
4514
  }
3596
4515
  }
@@ -3610,15 +4529,15 @@ function truncateContent(content) {
3610
4529
 
3611
4530
  // src/loop.ts
3612
4531
  async function* runQuery(userInput, options) {
3613
- const { provider, history, signal } = options;
4532
+ const { provider, history, signal, sessionState } = options;
3614
4533
  const maxTurns = options.maxTurns ?? 50;
3615
4534
  history.push({ role: "user", content: userInput });
3616
4535
  let reactiveAttempted = false;
3617
4536
  let turn = 0;
3618
4537
  while (turn < maxTurns) {
3619
4538
  turn++;
3620
- incrementTurn();
3621
- expireOldEntries();
4539
+ incrementTurn(sessionState?.microCompact);
4540
+ expireOldEntries(sessionState?.microCompact);
3622
4541
  if (signal?.aborted) {
3623
4542
  history.push({
3624
4543
  role: "user",
@@ -3680,7 +4599,12 @@ async function* runQuery(userInput, options) {
3680
4599
  if (isPromptTooLongError(e) && !reactiveAttempted) {
3681
4600
  reactiveAttempted = true;
3682
4601
  yield { type: "compact_start" };
3683
- const result = await reactiveCompactIfApplicable(history, provider, e);
4602
+ const result = await reactiveCompactIfApplicable(
4603
+ history,
4604
+ provider,
4605
+ e,
4606
+ sessionState?.reactive
4607
+ );
3684
4608
  if (result.recovered) {
3685
4609
  history.length = 0;
3686
4610
  history.push(...result.messages);
@@ -3728,7 +4652,11 @@ async function* runQuery(userInput, options) {
3728
4652
  };
3729
4653
  const result = await executeTool(tc.function.name, tc.function.arguments, signal);
3730
4654
  const rawContent = result.ok ? result.content : `Error: ${result.error}`;
3731
- const content = microCompact(tc.function.name, rawContent);
4655
+ const content = microCompact(
4656
+ tc.function.name,
4657
+ rawContent,
4658
+ sessionState?.microCompact
4659
+ );
3732
4660
  history.push({
3733
4661
  role: "tool",
3734
4662
  content,
@@ -3768,6 +4696,179 @@ function previewArgs(rawJson) {
3768
4696
  return oneLine.slice(0, 60) + "...";
3769
4697
  }
3770
4698
 
4699
+ // src/plugins/pluginRunner.ts
4700
+ var DEFAULT_MAX_ITERATIONS = 50;
4701
+ var SAFETY_CEILING = 200;
4702
+ function extractMaxIterations(args) {
4703
+ const match = args.match(/--max-iterations\s+(\d+)/i);
4704
+ return match ? parseInt(match[1], 10) : void 0;
4705
+ }
4706
+ async function* runWithPlugins(userInput, options) {
4707
+ const { provider, history, signal } = options;
4708
+ const isSlashCommand = userInput.trimStart().startsWith("/");
4709
+ let currentInput = userInput;
4710
+ let matchedCmd = null;
4711
+ if (isSlashCommand) {
4712
+ await discoverPlugins();
4713
+ matchedCmd = resolveCommand(userInput);
4714
+ if (matchedCmd) {
4715
+ currentInput = buildCommandInput(matchedCmd);
4716
+ }
4717
+ }
4718
+ const activeHookPlugins = matchedCmd ? getActiveStopHookPlugins() : [];
4719
+ const enterLoop = matchedCmd !== null && activeHookPlugins.length > 0;
4720
+ if (!enterLoop) {
4721
+ yield* runQuery(currentInput, {
4722
+ provider,
4723
+ history,
4724
+ signal,
4725
+ maxTurns: options.maxTurns,
4726
+ sessionState: options.sessionState
4727
+ });
4728
+ return;
4729
+ }
4730
+ const cmd = matchedCmd.cmd;
4731
+ const maxIter = Math.min(
4732
+ extractMaxIterations(currentInput) ?? DEFAULT_MAX_ITERATIONS,
4733
+ SAFETY_CEILING
4734
+ );
4735
+ yield {
4736
+ type: "plugin_start",
4737
+ pluginName: cmd.pluginName,
4738
+ description: cmd.description
4739
+ };
4740
+ const checks = parseVerifyArgs(matchedCmd.arguments);
4741
+ const goalState = new GoalState(getWorkingDir(), cmd.pluginName);
4742
+ await goalState.reset();
4743
+ await goalState.init(currentInput, checks);
4744
+ await goalState.appendProgress(`=== Loop \u542F\u52A8 === \u76EE\u6807: ${currentInput.slice(0, 120)}...`);
4745
+ const baseHistory = history.slice();
4746
+ let iterationCount = 0;
4747
+ let consecutiveFailures = 0;
4748
+ let finalAssistantMsg = null;
4749
+ try {
4750
+ do {
4751
+ iterationCount++;
4752
+ if (iterationCount > maxIter) {
4753
+ await goalState.forceSetPhase("done" /* DONE */, `\u8FBE\u5230\u8FED\u4EE3\u4E0A\u9650 ${maxIter}`);
4754
+ await goalState.appendLearning(`[\u8FED\u4EE3\u4E0A\u9650] \u5FAA\u73AF\u5728 ${iterationCount - 1} \u8F6E\u540E\u5F3A\u5236\u7EC8\u6B62\uFF0C\u53EF\u80FD\u76EE\u6807\u8FC7\u5927\u6216\u9677\u5165\u6B7B\u5FAA\u73AF`);
4755
+ yield { type: "error", error: `Loop \u5DF2\u8FBE\u8FED\u4EE3\u4E0A\u9650 ${maxIter}\uFF0C\u81EA\u52A8\u505C\u6B62` };
4756
+ return;
4757
+ }
4758
+ if (signal?.aborted) {
4759
+ yield { type: "interrupted" };
4760
+ return;
4761
+ }
4762
+ yield {
4763
+ type: "plugin_iteration",
4764
+ pluginName: cmd.pluginName,
4765
+ current: iterationCount,
4766
+ max: maxIter
4767
+ };
4768
+ history.length = 0;
4769
+ history.push(...baseHistory);
4770
+ const freshContext = goalState.composeContext(iterationCount);
4771
+ const enhancedInput = `${freshContext}
4772
+
4773
+ ${currentInput}`;
4774
+ yield* runQuery(enhancedInput, {
4775
+ provider,
4776
+ history,
4777
+ signal,
4778
+ maxTurns: options.maxTurns,
4779
+ sessionState: options.sessionState
4780
+ });
4781
+ if (signal?.aborted) {
4782
+ yield { type: "interrupted" };
4783
+ return;
4784
+ }
4785
+ const lastAssistantIdx = (() => {
4786
+ for (let i = history.length - 1; i >= 0; i--) {
4787
+ if (history[i].role === "assistant") return i;
4788
+ }
4789
+ return -1;
4790
+ })();
4791
+ finalAssistantMsg = lastAssistantIdx >= 0 ? history[lastAssistantIdx] : null;
4792
+ const lastAssistantText = finalAssistantMsg ? typeof finalAssistantMsg.content === "string" ? finalAssistantMsg.content : JSON.stringify(finalAssistantMsg.content) : "";
4793
+ const hasCompleteSentinel = /<promise>(?:COMPLETE|DONE|GOAL_COMPLETE)<\/promise>/i.test(lastAssistantText);
4794
+ const hasNeedReplan = /<PROMISE>NEED_REPLAN<\/PROMISE>/i.test(lastAssistantText);
4795
+ if (hasCompleteSentinel) {
4796
+ await goalState.forceSetPhase("verify" /* VERIFY */, "\u68C0\u6D4B\u5230\u5B8C\u6210\u54E8\u5175\uFF0C\u8FDB\u5165\u9A8C\u8BC1");
4797
+ await goalState.appendProgress(`\u8FED\u4EE3 ${iterationCount}: \u68C0\u6D4B\u5230\u5B8C\u6210\u54E8\u5175\uFF0C\u8FD0\u884C\u9A8C\u8BC1\u95E8...`);
4798
+ if (checks.length > 0) {
4799
+ const vResult = await runVerification(checks);
4800
+ if (!vResult.passed) {
4801
+ consecutiveFailures++;
4802
+ await goalState.appendLearning(
4803
+ `[\u8FED\u4EE3 ${iterationCount}] \u58F0\u79F0\u5B8C\u6210\u4F46\u9A8C\u8BC1\u672A\u901A\u8FC7: ${vResult.summary}`
4804
+ );
4805
+ yield {
4806
+ type: "error",
4807
+ error: `\u26A0\uFE0F \u9A8C\u8BC1\u672A\u901A\u8FC7: ${vResult.summary}\u3002\u7EE7\u7EED\u5C1D\u8BD5...`
4808
+ };
4809
+ if (consecutiveFailures >= 3) {
4810
+ await goalState.forceSetPhase(
4811
+ "heal" /* HEAL */,
4812
+ `\u8FDE\u7EED ${consecutiveFailures} \u6B21\u9A8C\u8BC1\u5931\u8D25`
4813
+ );
4814
+ } else {
4815
+ await goalState.setPhase("build" /* BUILD */, "\u9A8C\u8BC1\u672A\u901A\u8FC7\uFF0C\u8FD4\u56DE\u6784\u5EFA");
4816
+ }
4817
+ continue;
4818
+ }
4819
+ await goalState.appendProgress(`\u2705 \u9A8C\u8BC1\u901A\u8FC7: ${vResult.summary}`);
4820
+ }
4821
+ await goalState.setPhase("done" /* DONE */, "goal complete & verified");
4822
+ yield {
4823
+ type: "plugin_iteration",
4824
+ pluginName: cmd.pluginName,
4825
+ current: iterationCount,
4826
+ max: maxIter
4827
+ };
4828
+ return;
4829
+ }
4830
+ if (hasNeedReplan) {
4831
+ await goalState.forceSetPhase("plan" /* PLAN */, "agent \u8BF7\u6C42\u91CD\u65B0\u89C4\u5212");
4832
+ await goalState.appendLearning("[NEED_REPLAN] Agent \u8BA4\u4E3A\u5F53\u524D\u65B9\u6848\u4E0D\u53EF\u884C\uFF0C\u9700\u8981\u91CD\u65B0\u89C4\u5212");
4833
+ await goalState.appendProgress("Agent \u8BF7\u6C42 NEED_REPLAN\uFF0C\u56DE PLAN \u9636\u6BB5");
4834
+ consecutiveFailures = 0;
4835
+ continue;
4836
+ }
4837
+ if (goalState.currentPhase === "plan" /* PLAN */ && iterationCount >= 2) {
4838
+ await goalState.setPhase("build" /* BUILD */, "\u89C4\u5212\u9636\u6BB5\u5DF2\u5B8C\u6210\uFF0C\u8FDB\u5165\u6784\u5EFA");
4839
+ }
4840
+ const hookTranscript = lastAssistantText;
4841
+ const hookResult = await executeStopHooks(activeHookPlugins, hookTranscript);
4842
+ if (hookResult.decision === "block" && hookResult.reason) {
4843
+ currentInput = hookResult.reason;
4844
+ consecutiveFailures = 0;
4845
+ await goalState.recordDecision(
4846
+ { iteration: iterationCount, phase: goalState.currentPhase, summary: "stop-hook \u53CD\u9988" },
4847
+ ["\u7EE7\u7EED\u5FAA\u73AF", "\u7EC8\u6B62"],
4848
+ "\u7EE7\u7EED\u5FAA\u73AF",
4849
+ hookResult.reason.slice(0, 200)
4850
+ );
4851
+ if (hookResult.systemMessage) {
4852
+ baseHistory.push({
4853
+ role: "user",
4854
+ content: `[Plugin Stop Hook] ${hookResult.systemMessage}`
4855
+ });
4856
+ }
4857
+ await goalState.appendProgress(`\u8FED\u4EE3 ${iterationCount}: Stop hook block\uFF0C\u6CE8\u5165\u53CD\u9988\u7EE7\u7EED`);
4858
+ } else {
4859
+ await goalState.appendProgress(`\u8FED\u4EE3 ${iterationCount}: \u65E0\u54E8\u5175 / hook pass\uFF0C\u7EE7\u7EED\u4E0B\u4E00\u8F6E`);
4860
+ }
4861
+ } while (true);
4862
+ } finally {
4863
+ history.length = 0;
4864
+ history.push(...baseHistory);
4865
+ if (finalAssistantMsg) {
4866
+ history.push(finalAssistantMsg);
4867
+ }
4868
+ await goalState.cleanup();
4869
+ }
4870
+ }
4871
+
3771
4872
  // src/ui/hooks/useChat.ts
3772
4873
  function useChat(args) {
3773
4874
  const historyRef = useRef3(args.initialHistory.slice());
@@ -3779,6 +4880,7 @@ function useChat(args) {
3779
4880
  const [error, setError] = useState5(null);
3780
4881
  const [interrupted, setInterrupted] = useState5(false);
3781
4882
  const [compacting, setCompacting] = useState5(false);
4883
+ const [pluginLoop, setPluginLoop] = useState5(null);
3782
4884
  const abortRef = useRef3(null);
3783
4885
  useEffect(() => {
3784
4886
  return () => {
@@ -3794,10 +4896,11 @@ function useChat(args) {
3794
4896
  setError(null);
3795
4897
  setInterrupted(false);
3796
4898
  setStreamingText("");
4899
+ setPluginLoop(null);
3797
4900
  const ac = new AbortController();
3798
4901
  abortRef.current = ac;
3799
4902
  try {
3800
- for await (const ev of runQuery(trimmed, {
4903
+ for await (const ev of runWithPlugins(trimmed, {
3801
4904
  provider: args.provider,
3802
4905
  history: historyRef.current,
3803
4906
  signal: ac.signal
@@ -3808,6 +4911,7 @@ function useChat(args) {
3808
4911
  setCompacting,
3809
4912
  setError,
3810
4913
  setInterrupted,
4914
+ setPluginLoop,
3811
4915
  bump
3812
4916
  });
3813
4917
  }
@@ -3818,6 +4922,7 @@ function useChat(args) {
3818
4922
  setStreamingText("");
3819
4923
  setToolStatus(null);
3820
4924
  setCompacting(false);
4925
+ setPluginLoop(null);
3821
4926
  abortRef.current = null;
3822
4927
  args.onPersist?.(historyRef.current);
3823
4928
  }
@@ -3873,6 +4978,7 @@ function useChat(args) {
3873
4978
  error,
3874
4979
  interrupted,
3875
4980
  compacting,
4981
+ pluginLoop,
3876
4982
  submit,
3877
4983
  abort,
3878
4984
  clearHistory,
@@ -3920,6 +5026,16 @@ function handleEvent(ev, setters) {
3920
5026
  setters.setError(ev.error);
3921
5027
  setters.bump();
3922
5028
  break;
5029
+ case "plugin_start":
5030
+ break;
5031
+ case "plugin_iteration":
5032
+ setters.setPluginLoop({
5033
+ pluginName: ev.pluginName,
5034
+ current: ev.current,
5035
+ max: ev.max ?? 0
5036
+ });
5037
+ setters.bump();
5038
+ break;
3923
5039
  }
3924
5040
  }
3925
5041
 
@@ -3968,7 +5084,7 @@ function App({ provider, initialHistory }) {
3968
5084
  onCompact: chat2.compactNow
3969
5085
  }
3970
5086
  ),
3971
- /* @__PURE__ */ jsx6(StatusLine, { provider, history: chat2.history })
5087
+ /* @__PURE__ */ jsx6(StatusLine, { provider, history: chat2.history, pluginLoop: chat2.pluginLoop })
3972
5088
  ] });
3973
5089
  }
3974
5090
 
@@ -4027,8 +5143,28 @@ function truncateForDisplay(content, max = TOOL_OUTPUT_PREVIEW_MAX) {
4027
5143
  return content.slice(0, max) + "...";
4028
5144
  }
4029
5145
  function extractPromptArgs(args) {
4030
- const FLAGS = /* @__PURE__ */ new Set(["-p", "--print", "--verbose", "-v", "-h", "--help"]);
4031
- return args.filter((a) => !FLAGS.has(a));
5146
+ const FLAG_BOOLEAN = /* @__PURE__ */ new Set([
5147
+ "-p",
5148
+ "--print",
5149
+ "--verbose",
5150
+ "-v",
5151
+ "-h",
5152
+ "--help",
5153
+ "-V",
5154
+ "--version"
5155
+ ]);
5156
+ const FLAG_WITH_VALUE = /* @__PURE__ */ new Set(["-d", "--cwd"]);
5157
+ const result = [];
5158
+ for (let i = 0; i < args.length; i++) {
5159
+ const a = args[i];
5160
+ if (FLAG_BOOLEAN.has(a)) continue;
5161
+ if (FLAG_WITH_VALUE.has(a)) {
5162
+ i++;
5163
+ continue;
5164
+ }
5165
+ result.push(a);
5166
+ }
5167
+ return result;
4032
5168
  }
4033
5169
  async function runPrintMode(provider, args, initialHistory, options) {
4034
5170
  process.stdout.on("error", handleEPIPE(process.stdout));
@@ -4059,7 +5195,7 @@ async function runPrintMode(provider, args, initialHistory, options) {
4059
5195
  const history = initialHistory;
4060
5196
  const output = { buffer: "" };
4061
5197
  try {
4062
- for await (const event of runQuery(prompt, {
5198
+ for await (const event of runWithPlugins(prompt, {
4063
5199
  provider,
4064
5200
  history,
4065
5201
  signal: abortController.signal
@@ -4128,13 +5264,13 @@ function handleEvent2(event, output, verbose) {
4128
5264
  }
4129
5265
  }
4130
5266
  function readFromStdin() {
4131
- return new Promise((resolve10) => {
5267
+ return new Promise((resolve9) => {
4132
5268
  let data = "";
4133
5269
  let settled = false;
4134
5270
  const timer = setTimeout(() => {
4135
5271
  if (!settled) {
4136
5272
  settled = true;
4137
- resolve10("");
5273
+ resolve9("");
4138
5274
  }
4139
5275
  }, STDIN_TIMEOUT_MS);
4140
5276
  process.stdin.setEncoding("utf8");
@@ -4145,7 +5281,7 @@ function readFromStdin() {
4145
5281
  if (!settled) {
4146
5282
  clearTimeout(timer);
4147
5283
  settled = true;
4148
- resolve10(data.trim());
5284
+ resolve9(data.trim());
4149
5285
  }
4150
5286
  }
4151
5287
  process.stdin.on("data", onData);
@@ -4158,9 +5294,17 @@ import { jsx as jsx8 } from "react/jsx-runtime";
4158
5294
  var require2 = createRequire(import.meta.url);
4159
5295
  var pkg = require2("../package.json");
4160
5296
  async function main() {
5297
+ const args = process.argv.slice(2);
5298
+ const dirArg = extractCwdArg(args);
5299
+ if (dirArg) {
5300
+ const abs = resolve8(dirArg);
5301
+ if (!existsSync7(abs)) {
5302
+ mkdirSync(abs, { recursive: true });
5303
+ }
5304
+ process.chdir(abs);
5305
+ }
4161
5306
  initWorkingDir();
4162
5307
  await migrateLegacyContext(getWorkingDir());
4163
- const args = process.argv.slice(2);
4164
5308
  if (args.includes("-h") || args.includes("--help")) {
4165
5309
  printHelp();
4166
5310
  return;
@@ -4171,15 +5315,15 @@ async function main() {
4171
5315
  }
4172
5316
  const isPrintMode = args.includes("-p") || args.includes("--print");
4173
5317
  if (isPrintMode) {
4174
- let provider;
4175
- try {
4176
- provider = await loadProvider();
4177
- } catch (e) {
5318
+ const provider = await loadProviderLayered();
5319
+ if (!provider) {
4178
5320
  process.stderr.write(
4179
5321
  `
4180
- ${e.message}
5322
+ \u672A\u627E\u5230 provider \u914D\u7F6E\u3002
4181
5323
 
4182
- \u63D0\u793A\uFF1A-p \u6A21\u5F0F\u4E0D\u652F\u6301\u4EA4\u4E92\u914D\u7F6E\u3002\u8BF7\u5148\u76F4\u63A5\u8FD0\u884C \`minimal-agent\` \u5B8C\u6210\u9996\u6B21\u914D\u7F6E\u5411\u5BFC\uFF0C\u6216\u5728 .env \u4E2D\u624B\u52A8\u586B\u597D BASE_URL / API_KEY / MODEL\u3002
5324
+ \u8BF7\u4E8C\u9009\u4E00\uFF1A
5325
+ 1. \u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL
5326
+ 2. \u5148\u76F4\u63A5\u8FD0\u884C \`minimal-agent\`\uFF08TUI\uFF09\u5B8C\u6210\u9996\u6B21\u914D\u7F6E\u5411\u5BFC\uFF1B\u5411\u5BFC\u4F1A\u5199\u51FA ~/.minimal-agent/config.json\uFF0C\u4E4B\u540E -p \u6A21\u5F0F\u81EA\u52A8\u590D\u7528
4183
5327
 
4184
5328
  `
4185
5329
  );
@@ -4213,6 +5357,8 @@ minimal-agent - \u8F7B\u91CF\u7EA7 AI \u7F16\u7A0B\u52A9\u624B
4213
5357
  \u9009\u9879:
4214
5358
  -p, --print \u975E\u4EA4\u4E92\u6A21\u5F0F\uFF0C\u76F4\u63A5\u6267\u884C\u5355\u6B21\u95EE\u7B54
4215
5359
  -v, --verbose \u663E\u793A\u8BE6\u7EC6\u8F93\u51FA\uFF08\u5DE5\u5177\u8C03\u7528\u3001\u538B\u7F29\u4FE1\u606F\uFF09
5360
+ -d, --cwd <dir> \u6307\u5B9A\u5DE5\u4F5C\u76EE\u5F55\uFF08\u4E0D\u5B58\u5728\u81EA\u52A8\u521B\u5EFA\uFF09\uFF1B\u542F\u52A8\u65F6 chdir \u5230\u8FD9\u91CC\uFF0C
5361
+ \u4E0A\u4E0B\u6587\u6587\u4EF6\u3001\u5DE5\u5177\u76F8\u5BF9\u8DEF\u5F84\u3001.env \u52A0\u8F7D\u90FD\u4EE5\u6B64\u4E3A\u57FA\u51C6
4216
5362
  -h, --help \u663E\u793A\u5E2E\u52A9\u4FE1\u606F
4217
5363
 
4218
5364
  \u4F1A\u8BDD\u8BB0\u5FC6:
@@ -4224,6 +5370,7 @@ minimal-agent - \u8F7B\u91CF\u7EA7 AI \u7F16\u7A0B\u52A9\u624B
4224
5370
  minimal-agent -p "\u5E2E\u6211\u5199\u4E00\u4E2A hello world"
4225
5371
  echo "\u89E3\u91CA\u4EE3\u7801" | minimal-agent -p
4226
5372
  minimal-agent -p --verbose "\u8FD0\u884C\u6D4B\u8BD5\u5E76\u62A5\u544A\u7ED3\u679C"
5373
+ minimal-agent -p "\u5904\u7406\u8D44\u6599" -d /tmp/job-123 # \u5DE5\u4F5C\u76EE\u5F55\u9694\u79BB
4227
5374
  `);
4228
5375
  }
4229
5376
  main().catch((e) => {