@cryer/star-cli 0.3.6 → 0.3.8

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,13 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- computeRetryDelayMs,
4
- gitTreesDir,
5
- isRetryableStreamError,
6
- responseBodyOf,
7
- starHome,
8
- summarizeStreamError,
9
- userMemoryPath
10
- } from "./chunk-B6P4VUBE.js";
11
2
 
12
3
  // src/agent/loop.ts
13
4
  import { tool as aiTool, generateText as generateText3 } from "ai";
@@ -42,6 +33,17 @@ var ModelConfigSchema = z.object({
42
33
  // request carries no effort field and the server-side default applies.
43
34
  // Anthropic maps effort to a thinking budget instead — ignored there.
44
35
  reasoningEffort: z.string().min(1).optional(),
36
+ // Sampling temperature forwarded on every request. Unset = the AI SDK's
37
+ // own default applies (ai@4 sends 0 — it does NOT omit the field). Some
38
+ // endpoints reject anything but one explicit value (e.g. kimi-for-coding:
39
+ // "invalid temperature: only 1 is allowed") — set it here. No upper
40
+ // bound: ranges vary by provider and the server validates.
41
+ temperature: z.number().min(0).optional(),
42
+ // Per-model overrides of the top-level stream watchdog timeouts (unset =
43
+ // the global values apply). Raise streamIdleTimeoutSec for relays that
44
+ // buffer long generations and stall mid-stream between flushes.
45
+ streamIdleTimeoutSec: z.number().positive().optional(),
46
+ streamFirstChunkTimeoutSec: z.number().positive().optional(),
45
47
  // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
46
48
  // the model is treated as unpriced.
47
49
  promptPrice: z.number().nonnegative().optional(),
@@ -73,6 +75,10 @@ var HookConfigSchema = z.object({
73
75
  });
74
76
  var ConfigSchema = z.object({
75
77
  defaultModel: z.string().default(""),
78
+ // Optional [[models]] name for cheap auxiliary LLM calls (compaction
79
+ // summaries, session titles, turn completion checks) — Claude Code routes
80
+ // these to a Haiku-class model. Unset = the active main model does them.
81
+ smallModel: z.string().optional(),
76
82
  permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
77
83
  providers: z.array(ProviderConfigSchema).default([]),
78
84
  models: z.array(ModelConfigSchema).default([]),
@@ -91,10 +97,12 @@ var ConfigSchema = z.object({
91
97
  compactThresholdTokens: z.number().int().positive().optional(),
92
98
  // Seconds without any stream part before a stalled response is ended
93
99
  // gracefully (some relays never send the terminal chunks). Applies once
94
- // streaming has started; the first part gets a longer, fixed allowance.
100
+ // content has started streaming; the wait for the first content part gets
101
+ // a longer allowance (control/metadata parts don't count as content).
95
102
  streamIdleTimeoutSec: z.number().positive().default(20),
96
- // Seconds to wait for the very first stream part before giving up; slow
97
- // thinking-model relays can buffer for minutes before sending anything.
103
+ // Seconds to wait for the very first content part before giving up; slow
104
+ // thinking-model endpoints can stay silent for minutes after accepting
105
+ // the request.
98
106
  streamFirstChunkTimeoutSec: z.number().positive().default(300),
99
107
  // Extra attempts per model request when a stream fails transiently
100
108
  // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
@@ -150,11 +158,13 @@ import { generateText } from "ai";
150
158
  var CHARS_PER_TOKEN = 4;
151
159
  var MESSAGE_OVERHEAD = 4;
152
160
  var IMAGE_PART_TOKENS = 1024;
153
- var CJK_RE = /[\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/;
161
+ function isCjkCode(code) {
162
+ return code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519;
163
+ }
154
164
  function textWeight(text) {
155
165
  let cjk = 0;
156
- for (const ch of text) {
157
- if (CJK_RE.test(ch)) cjk++;
166
+ for (let i = 0; i < text.length; i++) {
167
+ if (isCjkCode(text.charCodeAt(i))) cjk++;
158
168
  }
159
169
  return text.length + cjk * (CHARS_PER_TOKEN - 1);
160
170
  }
@@ -183,8 +193,13 @@ function imagePartCount(message) {
183
193
  if (typeof message.content === "string" || message.role === "tool") return 0;
184
194
  return message.content.filter((part) => part.type === "image" || part.type === "file").length;
185
195
  }
196
+ var tokenCache = /* @__PURE__ */ new WeakMap();
186
197
  function estimateMessageTokens(message) {
187
- return Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
198
+ const cached = tokenCache.get(message);
199
+ if (cached !== void 0) return cached;
200
+ const tokens = Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
201
+ tokenCache.set(message, tokens);
202
+ return tokens;
188
203
  }
189
204
  function estimateTokens(messages) {
190
205
  return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
@@ -208,6 +223,16 @@ var SUMMARY_SYSTEM_PROMPT = [
208
223
  "- outstanding TODOs and next steps",
209
224
  "Output only the summary, no preamble."
210
225
  ].join("\n");
226
+ var MAX_SUMMARY_INPUT_CHARS = 8e4;
227
+ var SUMMARY_HEAD_CHARS = 24e3;
228
+ function truncateSummaryInput(transcript) {
229
+ if (transcript.length <= MAX_SUMMARY_INPUT_CHARS) return transcript;
230
+ const tailChars = MAX_SUMMARY_INPUT_CHARS - SUMMARY_HEAD_CHARS;
231
+ const omitted = transcript.length - MAX_SUMMARY_INPUT_CHARS;
232
+ return `${transcript.slice(0, SUMMARY_HEAD_CHARS)}
233
+ [... ${omitted} characters omitted ...]
234
+ ${transcript.slice(-tailChars)}`;
235
+ }
211
236
  function serializeMessage(message) {
212
237
  if (typeof message.content === "string") {
213
238
  return `${message.role}: ${message.content}`;
@@ -222,14 +247,15 @@ function serializeMessage(message) {
222
247
  });
223
248
  return `${message.role}: ${parts.join("\n")}`;
224
249
  }
225
- async function summarizeMessages(messages, model, signal) {
226
- const transcript = messages.map(serializeMessage).join("\n");
250
+ async function summarizeMessages(messages, model, signal, temperature) {
251
+ const transcript = truncateSummaryInput(messages.map(serializeMessage).join("\n"));
227
252
  const { text } = await generateText({
228
253
  model,
229
254
  system: SUMMARY_SYSTEM_PROMPT,
230
255
  prompt: `Summarize this conversation so far:
231
256
 
232
257
  ${transcript}`,
258
+ temperature,
233
259
  // A hung relay must not stall the whole turn on the summary: cap it at
234
260
  // 60s and let the caller's abort (Esc) cut it short as well; the caller
235
261
  // falls back to the truncation placeholder on any failure.
@@ -246,26 +272,29 @@ function compactMessages(messages, maxTokens, opts = {}) {
246
272
  const hasSystem = first?.role === "system";
247
273
  const head = hasSystem && first ? [first] : [];
248
274
  const rest = hasSystem ? messages.slice(1) : messages.slice();
275
+ const messageTokens = rest.map((message) => estimateMessageTokens(message));
249
276
  const turns = [];
250
- for (const message of rest) {
277
+ for (let i = 0; i < rest.length; i++) {
251
278
  const current = turns[turns.length - 1];
252
- if (message.role === "user" || !current) {
253
- turns.push([message]);
279
+ if (rest[i]?.role === "user" || !current) {
280
+ turns.push({ length: 1, tokens: messageTokens[i] ?? 0 });
254
281
  } else {
255
- current.push(message);
282
+ current.length += 1;
283
+ current.tokens += messageTokens[i] ?? 0;
256
284
  }
257
285
  }
286
+ const headTokens = estimateTokens(head);
287
+ const restTokens = estimateTokens(rest);
258
288
  let droppedCount = 0;
259
- let turnIndex = 0;
260
- while (turnIndex < turns.length) {
261
- const turn = turns[turnIndex];
289
+ let droppedTokens = 0;
290
+ for (const turn of turns) {
262
291
  if (rest.length - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
263
292
  break;
264
293
  }
265
294
  droppedCount += turn.length;
266
- turnIndex += 1;
267
- const candidate = [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)];
268
- if (estimateTokens(candidate) <= limit) {
295
+ droppedTokens += turn.tokens;
296
+ const candidateTokens = headTokens + estimateMessageTokens(placeholderMessage(droppedCount)) + (restTokens - droppedTokens);
297
+ if (candidateTokens <= limit) {
269
298
  break;
270
299
  }
271
300
  }
@@ -390,6 +419,12 @@ ${unstaged}`);
390
419
  return { status, diff: text, truncated };
391
420
  }
392
421
 
422
+ // src/core/http-error.ts
423
+ function responseBodyOf(error) {
424
+ const body = error.responseBody;
425
+ return typeof body === "string" && body.length > 0 ? body : void 0;
426
+ }
427
+
393
428
  // src/core/image.ts
394
429
  var MAX_IMAGE_DIMENSION = 2e3;
395
430
  var OVERSIZED_IMAGE_FALLBACK_BYTES = 5 * 1024 * 1024;
@@ -689,9 +724,579 @@ async function runHooks(event, hooks2, ctx) {
689
724
  return result;
690
725
  }
691
726
 
727
+ // src/llm/provider.ts
728
+ import { createAnthropic } from "@ai-sdk/anthropic";
729
+ import { createOpenAI } from "@ai-sdk/openai";
730
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
731
+
732
+ // src/llm/activity.ts
733
+ var trackers = /* @__PURE__ */ new WeakMap();
734
+ function streamActivity(signal) {
735
+ return trackers.get(signal);
736
+ }
737
+ function wrapFetchWithActivity(baseFetch = fetch) {
738
+ return async (input, init) => {
739
+ const response = await baseFetch(input, init);
740
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : null);
741
+ if (!signal || !response.body) return response;
742
+ const tracker = { lastChunkAt: Date.now() };
743
+ trackers.set(signal, tracker);
744
+ const reader = response.body.getReader();
745
+ const body = new ReadableStream({
746
+ async pull(controller) {
747
+ try {
748
+ const { done, value } = await reader.read();
749
+ if (done) {
750
+ controller.close();
751
+ return;
752
+ }
753
+ tracker.lastChunkAt = Date.now();
754
+ controller.enqueue(value);
755
+ } catch (error) {
756
+ controller.error(error);
757
+ }
758
+ },
759
+ cancel(reason) {
760
+ return reader.cancel(reason);
761
+ }
762
+ });
763
+ return new Response(body, {
764
+ status: response.status,
765
+ statusText: response.statusText,
766
+ headers: response.headers
767
+ });
768
+ };
769
+ }
770
+
771
+ // src/llm/registry.ts
772
+ function listModels(config) {
773
+ return config.models;
774
+ }
775
+ function resolveModelConfig(config, modelName) {
776
+ const name = modelName ?? config.defaultModel;
777
+ const model = config.models.find((m) => m.name === name);
778
+ if (!model) {
779
+ const available = config.models.map((m) => m.name).join(", ") || "(none)";
780
+ throw new Error(`Model "${name}" not found. Available models: ${available}`);
781
+ }
782
+ return model;
783
+ }
784
+
785
+ // src/llm/sse-normalize.ts
786
+ function splitJsonPrefix(text) {
787
+ if (!text.startsWith("{")) return null;
788
+ let depth = 0;
789
+ let inString = false;
790
+ let escaped = false;
791
+ for (let i = 0; i < text.length; i++) {
792
+ const ch = text[i];
793
+ if (inString) {
794
+ if (escaped) escaped = false;
795
+ else if (ch === "\\") escaped = true;
796
+ else if (ch === '"') inString = false;
797
+ continue;
798
+ }
799
+ if (ch === '"') inString = true;
800
+ else if (ch === "{") depth += 1;
801
+ else if (ch === "}") {
802
+ depth -= 1;
803
+ if (depth === 0) return [text.slice(0, i + 1), text.slice(i + 1).trimStart()];
804
+ }
805
+ }
806
+ return null;
807
+ }
808
+ var SseNormalizeTransform = class extends TransformStream {
809
+ constructor() {
810
+ let buffer = "";
811
+ let pending = null;
812
+ const flushPending = (controller) => {
813
+ if (pending === null) return;
814
+ controller.enqueue(`data: ${pending}
815
+
816
+ `);
817
+ pending = null;
818
+ };
819
+ const emitPayload = (payload, controller) => {
820
+ let rest = payload;
821
+ for (; ; ) {
822
+ const split = splitJsonPrefix(rest);
823
+ if (!split) {
824
+ pending = rest;
825
+ return;
826
+ }
827
+ const [json, tail] = split;
828
+ if (tail === "") {
829
+ pending = json;
830
+ return;
831
+ }
832
+ controller.enqueue(`data: ${json}
833
+
834
+ `);
835
+ if (tail.startsWith("data:")) {
836
+ rest = tail.slice(5).replace(/^ /, "");
837
+ continue;
838
+ }
839
+ if (tail.startsWith("{")) {
840
+ rest = tail;
841
+ continue;
842
+ }
843
+ controller.enqueue(`${tail}
844
+ `);
845
+ return;
846
+ }
847
+ };
848
+ const handleLine = (raw, controller) => {
849
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
850
+ if (line === "") {
851
+ flushPending(controller);
852
+ return;
853
+ }
854
+ if (line.startsWith("data:")) {
855
+ flushPending(controller);
856
+ emitPayload(line.slice(5).replace(/^ /, ""), controller);
857
+ return;
858
+ }
859
+ if (line.startsWith("{") && pending !== null) {
860
+ flushPending(controller);
861
+ emitPayload(line, controller);
862
+ return;
863
+ }
864
+ flushPending(controller);
865
+ controller.enqueue(`${line}
866
+ `);
867
+ };
868
+ super({
869
+ transform(chunk, controller) {
870
+ buffer += chunk;
871
+ let index = buffer.indexOf("\n");
872
+ while (index !== -1) {
873
+ handleLine(buffer.slice(0, index), controller);
874
+ buffer = buffer.slice(index + 1);
875
+ index = buffer.indexOf("\n");
876
+ }
877
+ },
878
+ flush(controller) {
879
+ if (buffer.length > 0) handleLine(buffer, controller);
880
+ flushPending(controller);
881
+ }
882
+ });
883
+ }
884
+ };
885
+ function createSseNormalizingFetch(baseFetch = fetch) {
886
+ return async (input, init) => {
887
+ const response = await baseFetch(input, init);
888
+ const contentType = response.headers.get("content-type") ?? "";
889
+ if (!response.body || !contentType.includes("text/event-stream")) {
890
+ return response;
891
+ }
892
+ const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new SseNormalizeTransform()).pipeThrough(new TextEncoderStream());
893
+ const headers = new Headers(response.headers);
894
+ headers.delete("content-length");
895
+ headers.delete("content-encoding");
896
+ return new Response(body, {
897
+ status: response.status,
898
+ statusText: response.statusText,
899
+ headers
900
+ });
901
+ };
902
+ }
903
+
904
+ // src/llm/provider.ts
905
+ function resolveApiKey(provider) {
906
+ const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
907
+ const apiKey = fromEnv ?? provider.apiKey;
908
+ if (!apiKey) {
909
+ const hint = provider.apiKeyEnv ? `set ${provider.apiKeyEnv} or configure apiKey` : "configure apiKey";
910
+ throw new Error(`Missing API key for provider "${provider.name}": ${hint}`);
911
+ }
912
+ return apiKey;
913
+ }
914
+ function createModel(config, modelName) {
915
+ const modelConfig = resolveModelConfig(config, modelName);
916
+ const provider = config.providers.find((p) => p.name === modelConfig.provider);
917
+ if (!provider) {
918
+ const available = config.providers.map((p) => p.name).join(", ") || "(none)";
919
+ throw new Error(
920
+ `Provider "${modelConfig.provider}" for model "${modelConfig.name}" not found. Available providers: ${available}`
921
+ );
922
+ }
923
+ const apiKey = resolveApiKey(provider);
924
+ const trackedFetch = wrapFetchWithActivity();
925
+ switch (provider.protocol) {
926
+ case "anthropic":
927
+ return createAnthropic({
928
+ baseURL: provider.baseURL,
929
+ apiKey,
930
+ headers: provider.headers,
931
+ fetch: trackedFetch
932
+ })(modelConfig.model);
933
+ case "openai-compatible":
934
+ return createOpenAICompatible({
935
+ name: provider.name,
936
+ baseURL: provider.baseURL,
937
+ apiKey,
938
+ headers: provider.headers,
939
+ fetch: trackedFetch
940
+ })(modelConfig.model, {}, { includeUsage: true });
941
+ case "openai-responses":
942
+ return createOpenAI({
943
+ baseURL: provider.baseURL,
944
+ apiKey,
945
+ headers: provider.headers,
946
+ // Relays fronting the Responses API sometimes batch several JSON
947
+ // events into one SSE data payload, which the SDK's parser cannot
948
+ // read; normalize the wire format back to one event per data line.
949
+ fetch: createSseNormalizingFetch(trackedFetch)
950
+ }).responses(modelConfig.model);
951
+ }
952
+ }
953
+ function reasoningEffortMetadata(config, modelName) {
954
+ const modelConfig = resolveModelConfig(config, modelName);
955
+ if (!modelConfig.reasoningEffort) return void 0;
956
+ const provider = config.providers.find((p) => p.name === modelConfig.provider);
957
+ if (!provider || provider.protocol === "anthropic") return void 0;
958
+ const key = provider.protocol === "openai-responses" ? "openai" : provider.name;
959
+ return { [key]: { reasoningEffort: modelConfig.reasoningEffort } };
960
+ }
961
+
962
+ // src/llm/retry.ts
963
+ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
964
+ "AI_NoSuchToolError",
965
+ "AI_InvalidToolArgumentsError",
966
+ "AI_InvalidPromptError",
967
+ "AI_NoSuchModelError",
968
+ "AI_LoadAPIKeyError",
969
+ "AI_TypeValidationError",
970
+ "AI_JSONParseError"
971
+ ]);
972
+ var RETRYABLE_MESSAGE_PATTERNS = [
973
+ /\b(408|425|429|500|502|503|504|520|521|522|523|524|529)\b/,
974
+ /rate.?limit|too many requests|rate increased/i,
975
+ /overloaded|service.?unavailable|internal (server )?error|server error|provider returned error|bad gateway|gateway timeout/i,
976
+ /terminated|fetch failed|failed to fetch|network.?error|upstream connect|connection (?:error|refused|lost|reset)|socket (?:hang up|connection)|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout|epipe|ehostunreach|enetunreach/i,
977
+ /\b(?:request|response|connection|network|stream|read|headers?|body) (?:timeout|timed out|time out)\b/i,
978
+ /resource.?exhausted|try (?:your request )?again|at capacity/i
979
+ ];
980
+ function statusCodeOf(error) {
981
+ const status = error.statusCode;
982
+ return typeof status === "number" ? status : void 0;
983
+ }
984
+ function matchesRetryablePattern(text) {
985
+ return typeof text === "string" && RETRYABLE_MESSAGE_PATTERNS.some((p) => p.test(text));
986
+ }
987
+ function isRetryableStreamError(error) {
988
+ if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) return false;
989
+ if (error.isRetryable === true) return true;
990
+ const status = statusCodeOf(error);
991
+ if (status !== void 0) {
992
+ if (status === 408 || status === 429 || status >= 500 && status !== 501) return true;
993
+ return matchesRetryablePattern(error.message) || matchesRetryablePattern(responseBodyOf(error));
994
+ }
995
+ return true;
996
+ }
997
+ var RETRY_JITTER_FACTOR = 0.25;
998
+ var MAX_RETRY_DELAY_MS = 6e4;
999
+ function retryAfterDelayMs(error) {
1000
+ const headers = error.responseHeaders;
1001
+ if (!headers || typeof headers !== "object") return void 0;
1002
+ const normalized = {};
1003
+ for (const [key, value] of Object.entries(headers)) {
1004
+ if (typeof value === "string") normalized[key.toLowerCase()] = value;
1005
+ }
1006
+ const afterMs = Number.parseFloat(normalized["retry-after-ms"] ?? "");
1007
+ if (!Number.isNaN(afterMs)) return Math.max(0, afterMs);
1008
+ const after = normalized["retry-after"];
1009
+ if (after) {
1010
+ const seconds = Number.parseFloat(after);
1011
+ if (!Number.isNaN(seconds)) return Math.max(0, Math.ceil(seconds * 1e3));
1012
+ const dateMs = Date.parse(after) - Date.now();
1013
+ if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs);
1014
+ }
1015
+ return void 0;
1016
+ }
1017
+ function computeRetryDelayMs(attempt, baseMs, error, random = Math.random()) {
1018
+ const hinted = error ? retryAfterDelayMs(error) : void 0;
1019
+ if (hinted !== void 0) return Math.min(hinted, MAX_RETRY_DELAY_MS);
1020
+ const base = baseMs * 2 ** attempt;
1021
+ return Math.min(Math.ceil(base + base * RETRY_JITTER_FACTOR * random), MAX_RETRY_DELAY_MS);
1022
+ }
1023
+ function summarizeStreamError(error) {
1024
+ const parts = [error.message || error.name];
1025
+ const status = statusCodeOf(error);
1026
+ if (status !== void 0 && !error.message.includes(String(status))) {
1027
+ parts.push(`(HTTP ${status})`);
1028
+ }
1029
+ const body = responseBodyOf(error);
1030
+ if (body && !error.message.includes(body.slice(0, 40))) {
1031
+ const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200);
1032
+ if (excerpt) parts.push(`\u2014 ${excerpt}`);
1033
+ }
1034
+ return parts.join(" ");
1035
+ }
1036
+
1037
+ // src/llm/stream.ts
1038
+ import { streamText } from "ai";
1039
+
1040
+ // src/llm/debug.ts
1041
+ import { appendFileSync } from "fs";
1042
+ import path2 from "path";
1043
+
1044
+ // src/config/paths.ts
1045
+ import os from "os";
1046
+ import path from "path";
1047
+ function starHome() {
1048
+ return process.env.STAR_HOME ?? path.join(os.homedir(), ".star-cli");
1049
+ }
1050
+ function globalConfigPath() {
1051
+ return path.join(starHome(), "config.toml");
1052
+ }
1053
+ function sessionsDir() {
1054
+ return path.join(starHome(), "sessions");
1055
+ }
1056
+ function userMemoryPath() {
1057
+ return path.join(starHome(), "MEMORY.md");
1058
+ }
1059
+ function gitTreesDir() {
1060
+ return path.join(starHome(), "git-trees");
1061
+ }
1062
+ function envFilePath() {
1063
+ return path.join(starHome(), ".env");
1064
+ }
1065
+ function projectConfigPath(cwd) {
1066
+ return path.join(cwd, ".star", "config.toml");
1067
+ }
1068
+
1069
+ // src/llm/debug.ts
1070
+ function debugStreamLog(event, data) {
1071
+ if (!process.env.STAR_DEBUG) return;
1072
+ try {
1073
+ const line = `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data })}
1074
+ `;
1075
+ appendFileSync(path2.join(starHome(), "debug.log"), line);
1076
+ } catch {
1077
+ }
1078
+ }
1079
+
1080
+ // src/llm/stream.ts
1081
+ var DEFAULT_IDLE_TIMEOUT_MS = 2e4;
1082
+ var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
1083
+ var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning", "tool-call", "tool-call-delta"]);
1084
+ var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
1085
+ var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
1086
+ var CACHED_PROMPT_KEYS = ["cachedPromptTokens", "cached_prompt_tokens", "cachedTokens"];
1087
+ function withAnthropicCacheBreakpoints(model, messages) {
1088
+ if (!model.provider.startsWith("anthropic")) return messages;
1089
+ const mark = (message) => ({
1090
+ ...message,
1091
+ experimental_providerMetadata: {
1092
+ ...message.experimental_providerMetadata,
1093
+ anthropic: {
1094
+ ...message.experimental_providerMetadata?.anthropic,
1095
+ cacheControl: { type: "ephemeral" }
1096
+ }
1097
+ }
1098
+ });
1099
+ const result = messages.slice();
1100
+ if (result[0]?.role === "system") result[0] = mark(result[0]);
1101
+ const last = result.length - 1;
1102
+ if (last > 0 && result[last]) result[last] = mark(result[last]);
1103
+ return result;
1104
+ }
1105
+ function extractCacheUsage(providerMetadata) {
1106
+ if (!providerMetadata || typeof providerMetadata !== "object") return {};
1107
+ for (const value of Object.values(providerMetadata)) {
1108
+ if (!value || typeof value !== "object") continue;
1109
+ const record = value;
1110
+ for (const key of CACHE_READ_KEYS) {
1111
+ const n = record[key];
1112
+ if (typeof n === "number" && Number.isFinite(n)) return { cacheReadInputTokens: n };
1113
+ }
1114
+ for (const key of CACHED_PROMPT_KEYS) {
1115
+ const n = record[key];
1116
+ if (typeof n === "number" && Number.isFinite(n)) return { cachedPromptTokens: n };
1117
+ }
1118
+ }
1119
+ return {};
1120
+ }
1121
+ async function* streamChat(opts) {
1122
+ const controller = new AbortController();
1123
+ const onAbort = () => controller.abort();
1124
+ opts.abortSignal?.addEventListener("abort", onAbort);
1125
+ if (opts.abortSignal?.aborted) controller.abort();
1126
+ const { openai: openaiMetadata, ...otherMetadata } = opts.providerMetadata ?? {};
1127
+ const result = streamText({
1128
+ model: opts.model,
1129
+ messages: withAnthropicCacheBreakpoints(opts.model, opts.messages),
1130
+ tools: opts.tools,
1131
+ abortSignal: controller.signal,
1132
+ maxTokens: opts.maxTokens,
1133
+ temperature: opts.temperature,
1134
+ // Disable the SDK's own retries (ai@4 defaults to 2 internal attempts with
1135
+ // fixed backoff that ignores Retry-After and the abort signal, and
1136
+ // exhaustion throws an AI_RetryError stripped of status/headers/body) —
1137
+ // the retry policy in agent/loop.ts + llm/retry.ts owns the full budget
1138
+ // and needs the raw error to classify.
1139
+ maxRetries: 0,
1140
+ // Pass tool-call argument deltas through (instead of buffering until the
1141
+ // complete call) so large write_file payloads report receive progress.
1142
+ toolCallStreaming: true,
1143
+ experimental_providerMetadata: {
1144
+ // Only the openai provider (our responses-protocol path) reads these;
1145
+ // other providers ignore unknown metadata. The SDK's responses provider
1146
+ // sends every tool with strict:true by default (strictSchemas), and
1147
+ // strict mode requires every schema property to be required — our
1148
+ // classic function-calling schemas use optional properties, which
1149
+ // relays then reject. store:false matches how codex and opencode talk
1150
+ // to relays (no server-side response storage).
1151
+ ...otherMetadata,
1152
+ openai: { strictSchemas: false, store: false, ...openaiMetadata }
1153
+ }
1154
+ });
1155
+ const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
1156
+ const firstPartTimeoutMs = opts.firstPartTimeoutMs ?? DEFAULT_FIRST_PART_TIMEOUT_MS;
1157
+ const iterator = result.fullStream[Symbol.asyncIterator]();
1158
+ let seenContent = false;
1159
+ let deltas = 0;
1160
+ let hasContent = false;
1161
+ const argsProgress = /* @__PURE__ */ new Map();
1162
+ let lastPartAt = Date.now();
1163
+ debugStreamLog("request", {
1164
+ messages: opts.messages.length,
1165
+ idleTimeoutMs,
1166
+ firstPartTimeoutMs
1167
+ });
1168
+ try {
1169
+ for (; ; ) {
1170
+ let timer;
1171
+ const windowMs = seenContent ? idleTimeoutMs : firstPartTimeoutMs;
1172
+ const idle = new Promise((resolve) => {
1173
+ timer = setTimeout(() => resolve(null), windowMs);
1174
+ });
1175
+ let next;
1176
+ try {
1177
+ next = await Promise.race([iterator.next(), idle]);
1178
+ } catch (error) {
1179
+ debugStreamLog("stream-throw", {
1180
+ seenContent,
1181
+ deltas,
1182
+ error: error instanceof Error ? summarizeStreamError(error) : String(error)
1183
+ });
1184
+ throw error;
1185
+ } finally {
1186
+ if (timer) clearTimeout(timer);
1187
+ }
1188
+ if (next === null) {
1189
+ const activity = streamActivity(controller.signal);
1190
+ const silentForMs = Date.now() - lastPartAt;
1191
+ const lastChunkAgoMs = activity ? Date.now() - activity.lastChunkAt : void 0;
1192
+ if (lastChunkAgoMs !== void 0 && lastChunkAgoMs < windowMs && silentForMs < MAX_KEEPALIVE_SILENCE_MS) {
1193
+ debugStreamLog("keepalive", { seenContent, deltas, lastChunkAgoMs, silentForMs });
1194
+ continue;
1195
+ }
1196
+ debugStreamLog("idle-timeout", {
1197
+ seenContent,
1198
+ deltas,
1199
+ waitedMs: windowMs,
1200
+ lastChunkAgoMs
1201
+ });
1202
+ yield {
1203
+ type: "finish",
1204
+ finishReason: "idle-timeout",
1205
+ usage: void 0,
1206
+ // Content already streamed means the reply may be cut off
1207
+ // mid-thought; the loop surfaces a notice for that.
1208
+ ...hasContent ? { truncated: true } : {}
1209
+ };
1210
+ return;
1211
+ }
1212
+ if (next.done) {
1213
+ debugStreamLog("end", { seenContent, deltas });
1214
+ return;
1215
+ }
1216
+ const part = next.value;
1217
+ lastPartAt = Date.now();
1218
+ if (GENERATION_PART_TYPES.has(part.type)) seenContent = true;
1219
+ switch (part.type) {
1220
+ case "text-delta":
1221
+ deltas++;
1222
+ hasContent = true;
1223
+ yield { type: "text-delta", text: part.textDelta };
1224
+ break;
1225
+ case "reasoning":
1226
+ deltas++;
1227
+ yield { type: "reasoning", text: part.textDelta };
1228
+ break;
1229
+ case "tool-call":
1230
+ hasContent = true;
1231
+ yield {
1232
+ type: "tool-call",
1233
+ id: part.toolCallId,
1234
+ name: part.toolName,
1235
+ args: part.args
1236
+ };
1237
+ break;
1238
+ case "tool-call-streaming-start": {
1239
+ argsProgress.set(part.toolCallId, {
1240
+ name: part.toolName,
1241
+ chars: 0,
1242
+ nextMark: 4096
1243
+ });
1244
+ break;
1245
+ }
1246
+ case "tool-call-delta": {
1247
+ const progress = argsProgress.get(part.toolCallId) ?? {
1248
+ name: part.toolName,
1249
+ chars: 0,
1250
+ nextMark: 4096
1251
+ };
1252
+ progress.chars += part.argsTextDelta.length;
1253
+ argsProgress.set(part.toolCallId, progress);
1254
+ if (progress.chars >= progress.nextMark) {
1255
+ progress.nextMark *= 2;
1256
+ yield { type: "tool-call-progress", name: progress.name, bytes: progress.chars };
1257
+ }
1258
+ break;
1259
+ }
1260
+ case "finish": {
1261
+ const finite = (n) => Number.isFinite(n) ? n : 0;
1262
+ const cache4 = extractCacheUsage(
1263
+ part.providerMetadata ?? part.experimental_providerMetadata
1264
+ );
1265
+ debugStreamLog("finish", {
1266
+ finishReason: part.finishReason,
1267
+ usage: part.usage,
1268
+ deltas
1269
+ });
1270
+ yield {
1271
+ type: "finish",
1272
+ finishReason: part.finishReason,
1273
+ usage: part.usage ? {
1274
+ promptTokens: finite(part.usage.promptTokens),
1275
+ completionTokens: finite(part.usage.completionTokens),
1276
+ totalTokens: finite(part.usage.totalTokens),
1277
+ ...cache4
1278
+ } : void 0
1279
+ };
1280
+ return;
1281
+ }
1282
+ case "error": {
1283
+ if (opts.abortSignal?.aborted) return;
1284
+ const error = part.error instanceof Error ? part.error : new Error(String(part.error));
1285
+ debugStreamLog("error-part", { seenContent, deltas, error: summarizeStreamError(error) });
1286
+ yield { type: "error", error };
1287
+ break;
1288
+ }
1289
+ }
1290
+ }
1291
+ } finally {
1292
+ opts.abortSignal?.removeEventListener("abort", onAbort);
1293
+ controller.abort();
1294
+ }
1295
+ }
1296
+
692
1297
  // src/permissions/gate.ts
693
1298
  import fs from "fs";
694
- import path from "path";
1299
+ import path3 from "path";
695
1300
 
696
1301
  // src/core/sensitive.ts
697
1302
  var SENSITIVE_BASENAMES = /* @__PURE__ */ new Set([
@@ -734,6 +1339,239 @@ function isSensitivePath(p) {
734
1339
  return false;
735
1340
  }
736
1341
 
1342
+ // src/permissions/shell.ts
1343
+ var REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<", ">&", "<&"]);
1344
+ var FILE_REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<"]);
1345
+ var SEGMENT_SEPARATORS = /* @__PURE__ */ new Set([
1346
+ "&&",
1347
+ "||",
1348
+ "&",
1349
+ "|",
1350
+ ";",
1351
+ "\n",
1352
+ "(",
1353
+ ")"
1354
+ ]);
1355
+ function lexShell(command) {
1356
+ const tokens = [];
1357
+ let word = "";
1358
+ let doubleQuoted = false;
1359
+ let i = 0;
1360
+ const flush = () => {
1361
+ if (word.length > 0) {
1362
+ tokens.push({ kind: "word", text: word });
1363
+ word = "";
1364
+ }
1365
+ };
1366
+ const findCloseParen = (from) => {
1367
+ let depth = 0;
1368
+ let j = from;
1369
+ while (j < command.length) {
1370
+ const c = command[j];
1371
+ if (c === "\\") {
1372
+ j += 2;
1373
+ continue;
1374
+ }
1375
+ if (c === "'" || c === '"') {
1376
+ const close = command.indexOf(c, j + 1);
1377
+ j = close === -1 ? command.length : close + 1;
1378
+ continue;
1379
+ }
1380
+ if (c === "(") depth += 1;
1381
+ if (c === ")") {
1382
+ depth -= 1;
1383
+ if (depth === 0) return j + 1;
1384
+ }
1385
+ j += 1;
1386
+ }
1387
+ return -1;
1388
+ };
1389
+ const pushSubstitution = (openParen, process2) => {
1390
+ const close = findCloseParen(openParen);
1391
+ const innerEnd = close === -1 ? command.length : close - 1;
1392
+ flush();
1393
+ tokens.push({ kind: "subst", inner: command.slice(openParen + 1, innerEnd), process: process2 });
1394
+ return close === -1 ? command.length : close;
1395
+ };
1396
+ const lexDollarOrBacktick = (pos) => {
1397
+ const c = command[pos];
1398
+ if (c === "`") {
1399
+ const close = command.indexOf("`", pos + 1);
1400
+ const end = close === -1 ? command.length : close;
1401
+ flush();
1402
+ tokens.push({ kind: "subst", inner: command.slice(pos + 1, end), process: false });
1403
+ return close === -1 ? command.length : close + 1;
1404
+ }
1405
+ const next = command[pos + 1];
1406
+ if (next === "(") {
1407
+ return pushSubstitution(pos + 1, false);
1408
+ }
1409
+ if (next === "{") {
1410
+ const close = command.indexOf("}", pos + 2);
1411
+ if (close !== -1) {
1412
+ word += `$${command.slice(pos + 2, close)}`;
1413
+ return close + 1;
1414
+ }
1415
+ word += "$";
1416
+ return pos + 1;
1417
+ }
1418
+ if (next !== void 0 && /[A-Za-z_]/.test(next)) {
1419
+ let end = pos + 1;
1420
+ while (end < command.length && /[A-Za-z0-9_]/.test(command[end] ?? "")) {
1421
+ end += 1;
1422
+ }
1423
+ word += command.slice(pos, end);
1424
+ return end;
1425
+ }
1426
+ word += "$";
1427
+ return pos + 1;
1428
+ };
1429
+ while (i < command.length) {
1430
+ const c = command[i];
1431
+ if (doubleQuoted) {
1432
+ if (c === '"') {
1433
+ doubleQuoted = false;
1434
+ i += 1;
1435
+ } else if (c === "\\") {
1436
+ if (i + 1 < command.length) {
1437
+ word += command[i + 1];
1438
+ i += 2;
1439
+ } else {
1440
+ word += "\\";
1441
+ i += 1;
1442
+ }
1443
+ } else if (c === "$" || c === "`") {
1444
+ i = lexDollarOrBacktick(i);
1445
+ } else {
1446
+ word += c;
1447
+ i += 1;
1448
+ }
1449
+ continue;
1450
+ }
1451
+ if (c === "\\") {
1452
+ if (i + 1 < command.length) {
1453
+ word += command[i + 1];
1454
+ i += 2;
1455
+ } else {
1456
+ word += "\\";
1457
+ i += 1;
1458
+ }
1459
+ continue;
1460
+ }
1461
+ if (c === "'") {
1462
+ const close = command.indexOf("'", i + 1);
1463
+ const end = close === -1 ? command.length : close;
1464
+ word += command.slice(i + 1, end);
1465
+ i = close === -1 ? command.length : close + 1;
1466
+ continue;
1467
+ }
1468
+ if (c === '"') {
1469
+ doubleQuoted = true;
1470
+ i += 1;
1471
+ continue;
1472
+ }
1473
+ if (c === "$" || c === "`") {
1474
+ i = lexDollarOrBacktick(i);
1475
+ continue;
1476
+ }
1477
+ if (c === " " || c === " " || c === "\r") {
1478
+ flush();
1479
+ i += 1;
1480
+ continue;
1481
+ }
1482
+ if (c === "\n") {
1483
+ flush();
1484
+ tokens.push({ kind: "op", op: "\n" });
1485
+ i += 1;
1486
+ continue;
1487
+ }
1488
+ if (c === "&" || c === "|") {
1489
+ flush();
1490
+ if (command[i + 1] === c) {
1491
+ tokens.push({ kind: "op", op: c === "&" ? "&&" : "||" });
1492
+ i += 2;
1493
+ } else {
1494
+ tokens.push({ kind: "op", op: c });
1495
+ i += 1;
1496
+ }
1497
+ continue;
1498
+ }
1499
+ if (c === ";" || c === "(" || c === ")") {
1500
+ flush();
1501
+ tokens.push({ kind: "op", op: c });
1502
+ i += 1;
1503
+ continue;
1504
+ }
1505
+ if (c === ">" || c === "<") {
1506
+ flush();
1507
+ const next = command[i + 1];
1508
+ if (next === "(") {
1509
+ i = pushSubstitution(i + 1, true);
1510
+ continue;
1511
+ }
1512
+ if (c === ">" && next === ">") {
1513
+ tokens.push({ kind: "op", op: ">>" });
1514
+ i += 2;
1515
+ } else if (next === "&") {
1516
+ tokens.push({ kind: "op", op: c === ">" ? ">&" : "<&" });
1517
+ i += 2;
1518
+ } else {
1519
+ tokens.push({ kind: "op", op: c });
1520
+ i += 1;
1521
+ }
1522
+ continue;
1523
+ }
1524
+ word += c;
1525
+ i += 1;
1526
+ }
1527
+ flush();
1528
+ return tokens;
1529
+ }
1530
+ function splitShellSegments(tokens) {
1531
+ const segments = [];
1532
+ let current = [];
1533
+ for (const token of tokens) {
1534
+ if (token.kind === "op" && SEGMENT_SEPARATORS.has(token.op)) {
1535
+ if (current.length > 0) {
1536
+ segments.push(current);
1537
+ current = [];
1538
+ }
1539
+ continue;
1540
+ }
1541
+ current.push(token);
1542
+ }
1543
+ if (current.length > 0) {
1544
+ segments.push(current);
1545
+ }
1546
+ return segments;
1547
+ }
1548
+ function segmentWords(segment) {
1549
+ const words = [];
1550
+ const redirectTargets = [];
1551
+ for (let k = 0; k < segment.length; k += 1) {
1552
+ const token = segment[k];
1553
+ if (token.kind === "op" && REDIRECT_OPS.has(token.op)) {
1554
+ const target = segment[k + 1];
1555
+ if (target?.kind === "word") {
1556
+ redirectTargets.push(target.text);
1557
+ k += 1;
1558
+ }
1559
+ continue;
1560
+ }
1561
+ if (token.kind === "word") {
1562
+ words.push(token.text);
1563
+ }
1564
+ }
1565
+ return { words, redirectTargets };
1566
+ }
1567
+ function renderSegment(segment) {
1568
+ return segment.map((token) => {
1569
+ if (token.kind === "word") return token.text;
1570
+ if (token.kind === "op") return token.op;
1571
+ return token.process ? ">(\u2026)" : "$(\u2026)";
1572
+ }).join(" ");
1573
+ }
1574
+
737
1575
  // src/permissions/allow.ts
738
1576
  function parseAllowRule(rule) {
739
1577
  const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
@@ -759,7 +1597,15 @@ function matchAllowRule(rule, req) {
759
1597
  }
760
1598
  function splitCommandChain(command) {
761
1599
  if (command.includes("$(") || command.includes("`")) return null;
762
- return command.split(/&&|\|\||[;|\n]/).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
1600
+ const tokens = lexShell(command);
1601
+ if (tokens.some((token) => token.kind === "subst")) return null;
1602
+ const segments = splitShellSegments(tokens);
1603
+ for (const segment of segments) {
1604
+ if (segment.some((token) => token.kind === "op" && FILE_REDIRECT_OPS.has(token.op))) {
1605
+ return null;
1606
+ }
1607
+ }
1608
+ return segments.map(renderSegment).filter((segment) => segment.length > 0);
763
1609
  }
764
1610
  function withCommand(req, command) {
765
1611
  return {
@@ -796,27 +1642,206 @@ function buildAllowRule(req) {
796
1642
  }
797
1643
 
798
1644
  // src/permissions/gate.ts
799
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "grep", "glob"]);
800
- var DANGEROUS_COMMAND_PATTERNS = [
801
- /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
802
- /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
803
- /rm\s+-rf\s+\.\/?(?:\s|$|;|&)/i,
804
- /rm\s+-rf\s+\*(?:\s|$|;|&)/i,
805
- /\bsudo\s+rm\b/i,
806
- /\bdel\s+\/s\b/i,
807
- /\brd\s+\/s\b/i,
808
- /\bformat\s+[a-z]:/i,
809
- /\bdiskpart\b/i,
810
- /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
811
- /\bmkfs\b/i,
812
- /dd\s+[^|;]*\bif=/i,
813
- />\s*\/dev\/sd[a-z]/i,
814
- /\bshutdown\b/i,
815
- /\breboot\b/i,
816
- /\bpoweroff\b/i
817
- ];
1645
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "read_image", "grep", "glob"]);
1646
+ var FORK_BOMB = /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/;
1647
+ var MAX_ANALYSIS_DEPTH = 8;
1648
+ function baseName(word) {
1649
+ const base = word.replace(/\\/g, "/").split("/").pop() ?? word;
1650
+ return base.toLowerCase().replace(/\.exe$/, "");
1651
+ }
1652
+ function isDangerousDeleteTarget(target) {
1653
+ if (/^\/+$/.test(target)) return true;
1654
+ const norm = target.replace(/\\/g, "/").replace(/\/+$/, "");
1655
+ if (norm === "/*" || norm === "*") return true;
1656
+ if (norm === "~" || norm.startsWith("~/")) return true;
1657
+ if (norm === "." || norm === "./*") return true;
1658
+ if (/^[a-zA-Z]:$/.test(norm)) return true;
1659
+ if (["$HOME", "$USERPROFILE", "$HOMEPATH"].includes(norm)) return true;
1660
+ if (/^%(USERPROFILE|HOMEDRIVE|HOMEPATH)%$/i.test(norm)) return true;
1661
+ return false;
1662
+ }
1663
+ function rmIsDangerous(args) {
1664
+ let recursive = false;
1665
+ let optionsDone = false;
1666
+ const targets = [];
1667
+ for (const arg of args) {
1668
+ if (!optionsDone && arg === "--") {
1669
+ optionsDone = true;
1670
+ continue;
1671
+ }
1672
+ if (!optionsDone && arg.startsWith("-")) {
1673
+ if (arg === "--recursive") {
1674
+ recursive = true;
1675
+ } else if (!arg.startsWith("--") && /[rR]/.test(arg)) {
1676
+ recursive = true;
1677
+ }
1678
+ continue;
1679
+ }
1680
+ targets.push(arg);
1681
+ }
1682
+ return recursive && targets.some(isDangerousDeleteTarget);
1683
+ }
1684
+ function findIsDangerous(args) {
1685
+ if (!args.includes("-delete")) return false;
1686
+ const paths = [];
1687
+ for (const arg of args) {
1688
+ if (arg.startsWith("-") || arg === "(" || arg === ")" || arg === "!" || arg === ",") {
1689
+ break;
1690
+ }
1691
+ paths.push(arg);
1692
+ }
1693
+ return paths.length === 0 || paths.some(isDangerousDeleteTarget);
1694
+ }
1695
+ var XARGS_VALUE_OPTS = /* @__PURE__ */ new Set([
1696
+ "-I",
1697
+ "-n",
1698
+ "-P",
1699
+ "-s",
1700
+ "-L",
1701
+ "-d",
1702
+ "-E",
1703
+ "-a",
1704
+ "--replace",
1705
+ "--max-args",
1706
+ "--max-procs",
1707
+ "--max-lines",
1708
+ "--delimiter",
1709
+ "--eof",
1710
+ "--arg-file",
1711
+ "--interactive",
1712
+ "--exit"
1713
+ ]);
1714
+ function xargsIsDangerous(args) {
1715
+ for (let k = 0; k < args.length; k += 1) {
1716
+ const arg = args[k];
1717
+ if (XARGS_VALUE_OPTS.has(arg)) {
1718
+ k += 1;
1719
+ continue;
1720
+ }
1721
+ if (arg.startsWith("-")) {
1722
+ continue;
1723
+ }
1724
+ return baseName(arg) === "rm";
1725
+ }
1726
+ return false;
1727
+ }
1728
+ function chmodIsDangerous(args) {
1729
+ let recursive = false;
1730
+ let optionsDone = false;
1731
+ const operands = [];
1732
+ for (const arg of args) {
1733
+ if (!optionsDone && arg === "--") {
1734
+ optionsDone = true;
1735
+ continue;
1736
+ }
1737
+ if (!optionsDone && arg.startsWith("-")) {
1738
+ if (arg === "--recursive" || !arg.startsWith("--") && arg.includes("R")) {
1739
+ recursive = true;
1740
+ }
1741
+ continue;
1742
+ }
1743
+ operands.push(arg);
1744
+ }
1745
+ return recursive && operands.slice(1).some(isDangerousDeleteTarget);
1746
+ }
1747
+ function commandInvocation(words) {
1748
+ let sudo = false;
1749
+ let wrapped = false;
1750
+ let k = 0;
1751
+ for (; ; ) {
1752
+ const word = words[k];
1753
+ if (word === void 0) return null;
1754
+ const lower = word.toLowerCase();
1755
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
1756
+ k += 1;
1757
+ continue;
1758
+ }
1759
+ if (lower === "sudo" || lower === "doas" || lower === "env") {
1760
+ sudo = sudo || lower !== "env";
1761
+ wrapped = true;
1762
+ k += 1;
1763
+ continue;
1764
+ }
1765
+ if (wrapped && word.startsWith("-")) {
1766
+ k += 1;
1767
+ continue;
1768
+ }
1769
+ return { name: baseName(word), rest: words.slice(k + 1), sudo };
1770
+ }
1771
+ }
1772
+ function commandLineIsDangerous(command, depth) {
1773
+ if (depth > MAX_ANALYSIS_DEPTH) return true;
1774
+ const tokens = lexShell(command);
1775
+ for (const token of tokens) {
1776
+ if (token.kind === "subst" && commandLineIsDangerous(token.inner, depth + 1)) {
1777
+ return true;
1778
+ }
1779
+ }
1780
+ return splitShellSegments(tokens).some((segment) => segmentIsDangerous(segment, depth));
1781
+ }
1782
+ function segmentIsDangerous(segment, depth) {
1783
+ const { words, redirectTargets } = segmentWords(segment);
1784
+ if (redirectTargets.some((target) => /^\/dev\/sd[a-z]/i.test(target))) {
1785
+ return true;
1786
+ }
1787
+ const invocation = commandInvocation(words);
1788
+ if (!invocation) return false;
1789
+ const { name, rest, sudo } = invocation;
1790
+ if (sudo && name === "rm") return true;
1791
+ switch (name) {
1792
+ case "shutdown":
1793
+ case "reboot":
1794
+ case "poweroff":
1795
+ case "halt":
1796
+ case "diskpart":
1797
+ case "wipefs":
1798
+ case "shred":
1799
+ return true;
1800
+ case "rm":
1801
+ return rmIsDangerous(rest);
1802
+ case "rmdir":
1803
+ case "rd":
1804
+ case "del":
1805
+ case "erase":
1806
+ return rest.some((arg) => /^\/s$/i.test(arg));
1807
+ case "format":
1808
+ return rest.some((arg) => /^[a-z]:$/i.test(arg));
1809
+ case "dd":
1810
+ return rest.some((arg) => arg.startsWith("if="));
1811
+ case "find":
1812
+ return findIsDangerous(rest);
1813
+ case "xargs":
1814
+ return xargsIsDangerous(rest);
1815
+ case "chmod":
1816
+ return chmodIsDangerous(rest);
1817
+ case "systemctl":
1818
+ return rest.some((arg) => ["poweroff", "halt", "reboot"].includes(arg.toLowerCase()));
1819
+ case "init":
1820
+ return rest.some((arg) => arg === "0" || arg === "6");
1821
+ case "sh":
1822
+ case "bash":
1823
+ case "zsh":
1824
+ case "dash":
1825
+ case "ash": {
1826
+ const idx = rest.indexOf("-c");
1827
+ const script = idx >= 0 ? rest[idx + 1] : void 0;
1828
+ return script !== void 0 && commandLineIsDangerous(script, depth + 1);
1829
+ }
1830
+ case "eval":
1831
+ return rest.length > 0 && commandLineIsDangerous(rest.join(" "), depth + 1);
1832
+ case "cmd": {
1833
+ const idx = rest.findIndex((arg) => /^\/c$/i.test(arg));
1834
+ if (idx < 0) return false;
1835
+ const script = rest.slice(idx + 1).join(" ");
1836
+ return script.length > 0 && commandLineIsDangerous(script, depth + 1);
1837
+ }
1838
+ default:
1839
+ return name.startsWith("mkfs") && (name.length === 4 || /[.-]/.test(name.charAt(4)));
1840
+ }
1841
+ }
818
1842
  function isDangerousCommand(command) {
819
- return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
1843
+ if (FORK_BOMB.test(command)) return true;
1844
+ return commandLineIsDangerous(command, 0);
820
1845
  }
821
1846
  function getStringArg(args, key) {
822
1847
  if (typeof args === "object" && args !== null) {
@@ -828,7 +1853,7 @@ function getStringArg(args, key) {
828
1853
  return void 0;
829
1854
  }
830
1855
  function normalizeAbsolute(p, cwd) {
831
- const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
1856
+ const abs = path3.isAbsolute(p) ? path3.normalize(p) : path3.resolve(cwd, p);
832
1857
  return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
833
1858
  }
834
1859
  function isOutsideCwd(p, cwd) {
@@ -837,20 +1862,20 @@ function isOutsideCwd(p, cwd) {
837
1862
  return abs !== base && !abs.startsWith(`${base}/`);
838
1863
  }
839
1864
  function resolveRealPath(p, cwd) {
840
- let current = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
1865
+ let current = path3.isAbsolute(p) ? path3.normalize(p) : path3.resolve(cwd, p);
841
1866
  const missing = [];
842
1867
  for (; ; ) {
843
1868
  try {
844
- return path.join(fs.realpathSync(current), ...missing.reverse());
1869
+ return path3.join(fs.realpathSync(current), ...missing.reverse());
845
1870
  } catch {
846
- const parent = path.dirname(current);
1871
+ const parent = path3.dirname(current);
847
1872
  if (parent === current) return null;
848
- missing.push(path.basename(current));
1873
+ missing.push(path3.basename(current));
849
1874
  current = parent;
850
1875
  }
851
1876
  }
852
1877
  }
853
- function isOutsideForWrite(p, cwd) {
1878
+ function isOutsideResolved(p, cwd) {
854
1879
  const resolved = resolveRealPath(p, cwd);
855
1880
  if (resolved === null) return isOutsideCwd(p, cwd);
856
1881
  let realCwd;
@@ -875,8 +1900,7 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
875
1900
  if (isWriteTool && isSensitivePath(filePath)) {
876
1901
  return "deny";
877
1902
  }
878
- const outside = isWriteTool ? isOutsideForWrite(filePath, ctx.cwd) : isOutsideCwd(filePath, ctx.cwd);
879
- if (outside) {
1903
+ if (isOutsideResolved(filePath, ctx.cwd)) {
880
1904
  if (mode === "ask" && req.level === "read") {
881
1905
  return "ask";
882
1906
  }
@@ -950,17 +1974,40 @@ function scheduleSessionTitle(store, userText, model) {
950
1974
  // src/snapshot/git-tree.ts
951
1975
  import { execFile } from "child_process";
952
1976
  import { existsSync } from "fs";
953
- import { mkdir } from "fs/promises";
954
- import os from "os";
955
- import path2 from "path";
1977
+ import { mkdir, readdir, rm, writeFile } from "fs/promises";
1978
+ import os2 from "os";
1979
+ import path4 from "path";
956
1980
  var GIT_TIMEOUT_MS2 = 1e4;
1981
+ var GC_TIMEOUT_MS = 6e4;
957
1982
  var GIT_MAX_BUFFER2 = 8 * 1024 * 1024;
958
- function runGit2(args) {
1983
+ var GC_INTERVAL = 50;
1984
+ var TRACK_FAILURE_TTL_MS = 5 * 60 * 1e3;
1985
+ var SENSITIVE_EXCLUDE_PATTERNS = [
1986
+ ".env",
1987
+ ".env.*",
1988
+ "!.env.example",
1989
+ "!.env.sample",
1990
+ "!.env.template",
1991
+ "id_rsa",
1992
+ "id_dsa",
1993
+ "id_ecdsa",
1994
+ "id_ed25519",
1995
+ ".npmrc",
1996
+ ".netrc",
1997
+ ".pgpass",
1998
+ "*.pem",
1999
+ "*.key",
2000
+ "*.p12",
2001
+ "*.pfx",
2002
+ "**/.aws/credentials",
2003
+ "**/.kube/config"
2004
+ ];
2005
+ function runGit2(args, timeoutMs = GIT_TIMEOUT_MS2) {
959
2006
  return new Promise((resolve) => {
960
2007
  execFile(
961
2008
  "git",
962
2009
  args,
963
- { timeout: GIT_TIMEOUT_MS2, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
2010
+ { timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
964
2011
  (error, stdout) => {
965
2012
  resolve(error ? null : stdout.trim());
966
2013
  }
@@ -968,6 +2015,9 @@ function runGit2(args) {
968
2015
  });
969
2016
  }
970
2017
  var gitAvailable = null;
2018
+ var trackFailureUntil = /* @__PURE__ */ new Map();
2019
+ var trackCounts = /* @__PURE__ */ new Map();
2020
+ var scrubbedRepos = /* @__PURE__ */ new Set();
971
2021
  async function isGitAvailable() {
972
2022
  if (gitAvailable === null) {
973
2023
  gitAvailable = await runGit2(["--version"]) !== null;
@@ -975,41 +2025,119 @@ async function isGitAvailable() {
975
2025
  return gitAvailable;
976
2026
  }
977
2027
  function treeRepoDir(cwd) {
978
- const key = Buffer.from(path2.resolve(cwd), "utf8").toString("base64url");
979
- return path2.join(gitTreesDir(), key);
2028
+ const key = Buffer.from(path4.resolve(cwd), "utf8").toString("base64url");
2029
+ return path4.join(gitTreesDir(), key);
2030
+ }
2031
+ async function removeTreeRepo(cwd) {
2032
+ const dir = treeRepoDir(cwd);
2033
+ trackFailureUntil.delete(path4.resolve(cwd));
2034
+ trackCounts.delete(dir);
2035
+ scrubbedRepos.delete(dir);
2036
+ await rm(dir, { recursive: true, force: true }).catch(() => {
2037
+ });
2038
+ }
2039
+ async function pruneTreeReposExcept(remainingCwds) {
2040
+ let entries;
2041
+ try {
2042
+ entries = await readdir(gitTreesDir());
2043
+ } catch {
2044
+ return;
2045
+ }
2046
+ const keep = /* @__PURE__ */ new Set();
2047
+ for (const cwd of remainingCwds) keep.add(path4.basename(treeRepoDir(cwd)));
2048
+ await Promise.all(
2049
+ entries.filter((entry) => !keep.has(entry)).map(
2050
+ (entry) => rm(path4.join(gitTreesDir(), entry), { recursive: true, force: true }).catch(() => {
2051
+ })
2052
+ )
2053
+ );
980
2054
  }
981
2055
  function isGuardedDir(cwd) {
982
- const resolved = path2.resolve(cwd);
983
- const home = path2.resolve(os.homedir());
2056
+ const resolved = path4.resolve(cwd);
2057
+ const home = path4.resolve(os2.homedir());
984
2058
  const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
985
- return same(resolved, home) || same(resolved, path2.parse(resolved).root);
2059
+ return same(resolved, home) || same(resolved, path4.parse(resolved).root);
986
2060
  }
987
2061
  async function ensureTreeRepo(cwd) {
988
2062
  const dir = treeRepoDir(cwd);
989
- if (existsSync(path2.join(dir, "HEAD"))) return dir;
990
- await mkdir(path2.dirname(dir), { recursive: true });
991
- const out = await runGit2(["init", "--bare", dir]);
992
- return out === null ? null : dir;
2063
+ try {
2064
+ await mkdir(dir, { recursive: true, mode: 448 });
2065
+ if (!existsSync(path4.join(dir, "HEAD"))) {
2066
+ const out = await runGit2(["init", "--bare", dir]);
2067
+ if (out === null) return null;
2068
+ }
2069
+ await mkdir(path4.join(dir, "info"), { recursive: true });
2070
+ await writeFile(
2071
+ path4.join(dir, "info", "exclude"),
2072
+ `${SENSITIVE_EXCLUDE_PATTERNS.join("\n")}
2073
+ `,
2074
+ "utf8"
2075
+ );
2076
+ } catch {
2077
+ return null;
2078
+ }
2079
+ return dir;
993
2080
  }
994
2081
  function repoArgs(dir, cwd) {
995
2082
  return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
996
2083
  }
2084
+ async function untrackExcluded(dir, cwd) {
2085
+ const base = repoArgs(dir, cwd);
2086
+ const listed = await runGit2([...base, "ls-files", "-z", "-c", "-i", "--exclude-standard"]);
2087
+ if (listed === null) return false;
2088
+ const files = listed.split("\0").filter(Boolean);
2089
+ if (files.length === 0) return true;
2090
+ const removed = await runGit2([
2091
+ ...base,
2092
+ "rm",
2093
+ "--cached",
2094
+ "--ignore-unmatch",
2095
+ "-q",
2096
+ "--",
2097
+ ...files.map((file) => `:(literal)${file}`)
2098
+ ]);
2099
+ return removed !== null;
2100
+ }
997
2101
  async function trackTree(cwd) {
998
2102
  if (isGuardedDir(cwd)) return null;
2103
+ const key = path4.resolve(cwd);
2104
+ const failedUntil = trackFailureUntil.get(key);
2105
+ if (failedUntil !== void 0 && Date.now() < failedUntil) return null;
2106
+ const tree = await trackTreeUncached(cwd);
2107
+ if (tree === null) {
2108
+ trackFailureUntil.set(key, Date.now() + TRACK_FAILURE_TTL_MS);
2109
+ return null;
2110
+ }
2111
+ trackFailureUntil.delete(key);
2112
+ return tree;
2113
+ }
2114
+ async function trackTreeUncached(cwd) {
999
2115
  if (!await isGitAvailable()) return null;
1000
2116
  const dir = await ensureTreeRepo(cwd);
1001
2117
  if (!dir) return null;
1002
2118
  const base = repoArgs(dir, cwd);
2119
+ if (!scrubbedRepos.has(dir)) {
2120
+ if (!await untrackExcluded(dir, cwd)) return null;
2121
+ scrubbedRepos.add(dir);
2122
+ }
1003
2123
  if (await runGit2([...base, "add", "-A"]) === null) return null;
1004
- return runGit2([...base, "write-tree"]);
2124
+ const tree = await runGit2([...base, "write-tree"]);
2125
+ if (tree === null) return null;
2126
+ const count = (trackCounts.get(dir) ?? 0) + 1;
2127
+ trackCounts.set(dir, count);
2128
+ if (count % GC_INTERVAL === 0) {
2129
+ await runGit2([`--git-dir=${dir}`, "gc", "--auto"], GC_TIMEOUT_MS);
2130
+ }
2131
+ return tree;
1005
2132
  }
1006
2133
  async function restoreTree(cwd, hash) {
1007
2134
  if (isGuardedDir(cwd)) return false;
1008
2135
  if (!await isGitAvailable()) return false;
1009
- const dir = treeRepoDir(cwd);
1010
- if (!existsSync(path2.join(dir, "HEAD"))) return false;
2136
+ const dir = await ensureTreeRepo(cwd);
2137
+ if (!dir) return false;
1011
2138
  const base = repoArgs(dir, cwd);
1012
2139
  if (await runGit2([...base, "read-tree", hash]) === null) return false;
2140
+ if (!await untrackExcluded(dir, cwd)) return false;
1013
2141
  if (await runGit2([...base, "checkout-index", "-f", "-a"]) === null) return false;
1014
2142
  if (await runGit2([...base, "clean", "-fd"]) === null) return false;
1015
2143
  return true;
@@ -1017,21 +2145,23 @@ async function restoreTree(cwd, hash) {
1017
2145
  async function diffTreeNames(cwd, hash) {
1018
2146
  if (!await isGitAvailable()) return null;
1019
2147
  const dir = treeRepoDir(cwd);
1020
- if (!existsSync(path2.join(dir, "HEAD"))) return null;
2148
+ if (!existsSync(path4.join(dir, "HEAD"))) return null;
1021
2149
  const base = repoArgs(dir, cwd);
1022
2150
  const changed = await runGit2([...base, "diff", "--name-only", hash]);
1023
2151
  const untracked = await runGit2([...base, "ls-files", "--others", "--exclude-standard"]);
1024
2152
  if (changed === null || untracked === null) return null;
1025
2153
  const names = /* @__PURE__ */ new Set();
1026
2154
  for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
1027
- if (line !== "") names.add(line);
2155
+ if (line !== "" && !isSensitivePath(line)) names.add(line);
1028
2156
  }
1029
2157
  return [...names];
1030
2158
  }
1031
2159
 
1032
2160
  // src/tools/fs/snapshots.ts
1033
- import { mkdir as mkdir2, readFile, rm, stat, writeFile } from "fs/promises";
1034
- import path3 from "path";
2161
+ import { randomUUID } from "crypto";
2162
+ import { mkdir as mkdir2, readFile, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
2163
+ import os3 from "os";
2164
+ import path5 from "path";
1035
2165
  var MAX_SNAPSHOTS = 50;
1036
2166
  var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
1037
2167
  var stack = [];
@@ -1039,6 +2169,30 @@ var activeTurn = 0;
1039
2169
  var activeTurnMessageIndex = -1;
1040
2170
  var checkpointSeq = 0;
1041
2171
  var hooks = null;
2172
+ var spillDir = path5.join(
2173
+ os3.tmpdir(),
2174
+ `star-cli-snapshots-${process.pid}-${randomUUID().slice(0, 8)}`
2175
+ );
2176
+ function isSpillPath(p) {
2177
+ return p.startsWith(spillDir + path5.sep);
2178
+ }
2179
+ async function spillContent(snapshot) {
2180
+ if (snapshot.content === null || snapshot.contentFile) return;
2181
+ try {
2182
+ const file = path5.join(spillDir, `${snapshot.id}.snap`);
2183
+ await mkdir2(spillDir, { recursive: true, mode: 448 });
2184
+ await writeFile2(file, snapshot.content, { encoding: "utf8", mode: 384 });
2185
+ snapshot.contentFile = file;
2186
+ snapshot.content = null;
2187
+ } catch {
2188
+ }
2189
+ }
2190
+ async function discardSpill(snapshot) {
2191
+ if (snapshot.contentFile && isSpillPath(snapshot.contentFile)) {
2192
+ await rm2(snapshot.contentFile, { force: true }).catch(() => {
2193
+ });
2194
+ }
2195
+ }
1042
2196
  function setSnapshotHooks(next) {
1043
2197
  hooks = next;
1044
2198
  }
@@ -1097,8 +2251,10 @@ async function pushSnapshot(snapshot) {
1097
2251
  console.error("[star] failed to persist snapshot checkpoint:", err);
1098
2252
  }
1099
2253
  }
2254
+ await spillContent(snapshot);
1100
2255
  if (stack.length > MAX_SNAPSHOTS) {
1101
2256
  const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
2257
+ for (const old of evicted) await discardSpill(old);
1102
2258
  await removeFromStore(evicted.map((s) => s.id));
1103
2259
  }
1104
2260
  }
@@ -1109,9 +2265,11 @@ function listSnapshots() {
1109
2265
  return [...stack];
1110
2266
  }
1111
2267
  function clearSnapshots() {
2268
+ for (const snapshot of stack) void discardSpill(snapshot);
1112
2269
  stack.length = 0;
1113
2270
  }
1114
2271
  function hydrateSnapshots(snapshots) {
2272
+ for (const snapshot of stack) void discardSpill(snapshot);
1115
2273
  stack.length = 0;
1116
2274
  stack.push(...snapshots.slice(-MAX_SNAPSHOTS));
1117
2275
  for (const snapshot of stack) {
@@ -1136,11 +2294,11 @@ async function revert(snapshot) {
1136
2294
  if (content === null) {
1137
2295
  return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
1138
2296
  }
1139
- await mkdir2(path3.dirname(snapshot.path), { recursive: true });
1140
- await writeFile(snapshot.path, content, "utf8");
2297
+ await mkdir2(path5.dirname(snapshot.path), { recursive: true });
2298
+ await writeFile2(snapshot.path, content, "utf8");
1141
2299
  return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
1142
2300
  }
1143
- await rm(snapshot.path, { force: true });
2301
+ await rm2(snapshot.path, { force: true });
1144
2302
  return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
1145
2303
  }
1146
2304
  function listTurnSnapshots(turn, includeSubagent = false) {
@@ -1167,6 +2325,7 @@ async function undoTurnSnapshots(turn, includeSubagent = false) {
1167
2325
  const idx = stack.indexOf(snapshot);
1168
2326
  if (idx >= 0) stack.splice(idx, 1);
1169
2327
  revertedIds.push(snapshot.id);
2328
+ await discardSpill(snapshot);
1170
2329
  } catch (err) {
1171
2330
  messages.push(
1172
2331
  `Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
@@ -1178,13 +2337,16 @@ async function undoTurnSnapshots(turn, includeSubagent = false) {
1178
2337
  }
1179
2338
  async function dropTurnSnapshots(turn, includeSubagent = false) {
1180
2339
  const dropped = [];
2340
+ const droppedSnapshots = [];
1181
2341
  for (let i = stack.length - 1; i >= 0; i--) {
1182
2342
  const snapshot = stack[i];
1183
2343
  if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
1184
2344
  dropped.push(snapshot.id);
2345
+ droppedSnapshots.push(snapshot);
1185
2346
  stack.splice(i, 1);
1186
2347
  }
1187
2348
  }
2349
+ for (const snapshot of droppedSnapshots) await discardSpill(snapshot);
1188
2350
  await removeFromStore(dropped);
1189
2351
  return dropped.length;
1190
2352
  }
@@ -1206,6 +2368,7 @@ async function rewindToSnapshot(id, includeSubagent = false) {
1206
2368
  const idx = stack.indexOf(snapshot);
1207
2369
  if (idx >= 0) stack.splice(idx, 1);
1208
2370
  revertedIds.push(snapshot.id);
2371
+ await discardSpill(snapshot);
1209
2372
  if (snapshot.messageIndex >= 0) {
1210
2373
  messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
1211
2374
  }
@@ -1222,7 +2385,7 @@ async function rewindToSnapshot(id, includeSubagent = false) {
1222
2385
  // src/tools/bash.ts
1223
2386
  import { spawn as spawn3 } from "child_process";
1224
2387
  import { existsSync as existsSync2 } from "fs";
1225
- import path4 from "path";
2388
+ import path6 from "path";
1226
2389
  import { z as z2 } from "zod";
1227
2390
 
1228
2391
  // src/tasks/manager.ts
@@ -1241,6 +2404,9 @@ var TaskManager = class extends EventEmitter {
1241
2404
  const child = spawn2(spec.shell, spec.wrap(opts.command), {
1242
2405
  cwd: opts.cwd,
1243
2406
  windowsHide: true,
2407
+ // No stdin: background commands that read it would hang until the
2408
+ // (much longer) task timeout; give them EOF immediately.
2409
+ stdio: ["ignore", "pipe", "pipe"],
1244
2410
  // POSIX: the child leads its own process group so killTree can SIGKILL
1245
2411
  // the whole tree with a negative pid (Windows uses taskkill /t).
1246
2412
  detached: process.platform !== "win32"
@@ -1356,11 +2522,11 @@ var HALF_OUTPUT = MAX_OUTPUT / 2;
1356
2522
  var DEFAULT_TIMEOUT = 120;
1357
2523
  var MAX_TIMEOUT = 600;
1358
2524
  function findOnPath(exe, exclude) {
1359
- for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
2525
+ for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
1360
2526
  if (!dir || exclude?.(dir)) {
1361
2527
  continue;
1362
2528
  }
1363
- const full = path4.join(dir, exe);
2529
+ const full = path6.join(dir, exe);
1364
2530
  if (existsSync2(full)) {
1365
2531
  return full;
1366
2532
  }
@@ -1378,16 +2544,22 @@ function resolveShellUncached() {
1378
2544
  }
1379
2545
  const gitExe = findOnPath("git.exe");
1380
2546
  if (gitExe) {
1381
- const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
2547
+ const sibling = path6.join(path6.dirname(path6.dirname(gitExe)), "bin", "bash.exe");
1382
2548
  if (existsSync2(sibling)) {
1383
2549
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1384
2550
  }
1385
2551
  }
1386
- for (const candidate of [
1387
- "C:\\Program Files\\Git\\bin\\bash.exe",
1388
- "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1389
- "D:\\Git\\bin\\bash.exe"
2552
+ const seen = /* @__PURE__ */ new Set();
2553
+ for (const root of [
2554
+ process.env.ProgramFiles,
2555
+ process.env["ProgramFiles(x86)"],
2556
+ process.env.ProgramW6432
1390
2557
  ]) {
2558
+ if (!root) continue;
2559
+ const candidate = path6.join(root, "Git", "bin", "bash.exe");
2560
+ const key = candidate.toLowerCase();
2561
+ if (seen.has(key)) continue;
2562
+ seen.add(key);
1391
2563
  if (existsSync2(candidate)) {
1392
2564
  return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1393
2565
  }
@@ -1489,7 +2661,10 @@ description: ${args.description ?? "(none)"}`
1489
2661
  const child = spawn3(spec.shell, spec.wrap(args.command), {
1490
2662
  cwd: ctx.cwd,
1491
2663
  windowsHide: true,
1492
- detached: process.platform !== "win32"
2664
+ detached: process.platform !== "win32",
2665
+ // No stdin: a command that reads it (cat, read, input()) must get
2666
+ // EOF immediately instead of hanging until the timeout.
2667
+ stdio: ["ignore", "pipe", "pipe"]
1493
2668
  });
1494
2669
  const output = new BoundedOutput();
1495
2670
  let settled = false;
@@ -1538,9 +2713,10 @@ Command timed out after ${timeoutSeconds}s`,
1538
2713
  };
1539
2714
 
1540
2715
  // src/tools/fs/edit.ts
1541
- import { readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1542
- import path5 from "path";
2716
+ import { readFile as readFile2, stat as stat2, writeFile as writeFile3 } from "fs/promises";
2717
+ import path7 from "path";
1543
2718
  import { z as z3 } from "zod";
2719
+ var BINARY_PROBE_BYTES = 8192;
1544
2720
  var schema2 = z3.object({
1545
2721
  path: z3.string().describe("File path, absolute or relative to the working directory"),
1546
2722
  old_string: z3.string().min(1).describe("Exact text to replace"),
@@ -1549,16 +2725,27 @@ var schema2 = z3.object({
1549
2725
  });
1550
2726
  var editFileTool = {
1551
2727
  name: "edit_file",
1552
- description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set.",
2728
+ description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set. Refuses binary files and files over 5MB.",
1553
2729
  permission: "write",
1554
2730
  parameters: schema2,
1555
2731
  async execute(args, ctx) {
1556
- const filePath = path5.resolve(ctx.cwd, args.path);
2732
+ const filePath = path7.resolve(ctx.cwd, args.path);
1557
2733
  let content;
1558
2734
  let readMtimeMs;
1559
2735
  try {
1560
- content = await readFile2(filePath, "utf8");
1561
- readMtimeMs = (await stat2(filePath)).mtimeMs;
2736
+ const st = await stat2(filePath);
2737
+ if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
2738
+ return {
2739
+ content: `Refused to edit ${args.path}: file is ${st.size} bytes, over the 5MB limit (edits past it could not be restored by /undo)`,
2740
+ isError: true
2741
+ };
2742
+ }
2743
+ readMtimeMs = st.mtimeMs;
2744
+ const buf = await readFile2(filePath);
2745
+ if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
2746
+ return { content: `Refused to edit binary file: ${args.path}`, isError: true };
2747
+ }
2748
+ content = buf.toString("utf8");
1562
2749
  } catch (err) {
1563
2750
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1564
2751
  }
@@ -1586,7 +2773,7 @@ var editFileTool = {
1586
2773
  isError: true
1587
2774
  };
1588
2775
  }
1589
- await writeFile2(filePath, updated, "utf8");
2776
+ await writeFile3(filePath, updated, "utf8");
1590
2777
  } catch (err) {
1591
2778
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1592
2779
  }
@@ -1608,23 +2795,23 @@ var editFileTool = {
1608
2795
  };
1609
2796
 
1610
2797
  // src/tools/fs/glob.ts
1611
- import path7 from "path";
2798
+ import path9 from "path";
1612
2799
  import { z as z4 } from "zod";
1613
2800
 
1614
2801
  // src/tools/fs/util.ts
1615
2802
  import { readFileSync, statSync } from "fs";
1616
- import { readdir, stat as stat3 } from "fs/promises";
1617
- import path6 from "path";
2803
+ import { readdir as readdir2, stat as stat3 } from "fs/promises";
2804
+ import path8 from "path";
1618
2805
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
1619
2806
  async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1620
2807
  const out = [];
1621
2808
  async function walk(dir, relBase) {
1622
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
2809
+ const entries = await readdir2(dir, { withFileTypes: true }).catch(() => null);
1623
2810
  if (!entries) {
1624
2811
  return;
1625
2812
  }
1626
2813
  for (const entry of entries) {
1627
- const abs = path6.join(dir, entry.name);
2814
+ const abs = path8.join(dir, entry.name);
1628
2815
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1629
2816
  if (entry.isDirectory()) {
1630
2817
  if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
@@ -1783,7 +2970,7 @@ function parseIgnorePatterns(body) {
1783
2970
  return out;
1784
2971
  }
1785
2972
  function loadIgnorePatterns(dir) {
1786
- const file = path6.join(dir, IGNORE_FILE);
2973
+ const file = path8.join(dir, IGNORE_FILE);
1787
2974
  let mtimeMs;
1788
2975
  try {
1789
2976
  mtimeMs = statSync(file).mtimeMs;
@@ -1808,9 +2995,9 @@ function createIgnorePredicate(cwd) {
1808
2995
  if (patterns.length === 0) {
1809
2996
  return void 0;
1810
2997
  }
1811
- const root = path6.resolve(cwd);
2998
+ const root = path8.resolve(cwd);
1812
2999
  return (absPath, isDir) => {
1813
- const rel = path6.relative(root, absPath).split(path6.sep).join("/");
3000
+ const rel = path8.relative(root, absPath).split(path8.sep).join("/");
1814
3001
  if (!rel || rel.startsWith("..")) {
1815
3002
  return false;
1816
3003
  }
@@ -1846,15 +3033,24 @@ var schema3 = z4.object({
1846
3033
  ),
1847
3034
  path: z4.string().optional().describe("Directory to search, defaults to the working directory")
1848
3035
  });
3036
+ function hasParentSegment(pattern) {
3037
+ return pattern.replace(/\\/g, "/").split("/").some((segment) => segment === "..");
3038
+ }
1849
3039
  var globTool = {
1850
3040
  name: "glob",
1851
3041
  description: "Find files by glob pattern. Skips node_modules, .git and dist. Returns paths sorted by modification time, newest first, up to 100 results.",
1852
3042
  permission: "read",
1853
3043
  parameters: schema3,
1854
3044
  async execute(args, ctx) {
1855
- const root = path7.resolve(ctx.cwd, args.path ?? ".");
3045
+ if (hasParentSegment(args.pattern)) {
3046
+ return {
3047
+ content: `Refused glob pattern with '..' segment: ${args.pattern}`,
3048
+ isError: true
3049
+ };
3050
+ }
3051
+ const root = path9.resolve(ctx.cwd, args.path ?? ".");
1856
3052
  const prefixSegments = literalDirPrefix(args.pattern);
1857
- const walkRoot = prefixSegments.length > 0 ? path7.join(root, ...prefixSegments) : root;
3053
+ const walkRoot = prefixSegments.length > 0 ? path9.join(root, ...prefixSegments) : root;
1858
3054
  const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
1859
3055
  const files = await walkFiles(walkRoot, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1860
3056
  const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
@@ -1866,8 +3062,8 @@ var globTool = {
1866
3062
  };
1867
3063
 
1868
3064
  // src/tools/fs/grep.ts
1869
- import { readFile as readFile3, stat as stat4 } from "fs/promises";
1870
- import path8 from "path";
3065
+ import { readFile as readFile3, realpath, stat as stat4 } from "fs/promises";
3066
+ import path10 from "path";
1871
3067
  import { z as z5 } from "zod";
1872
3068
  var MAX_MATCHES = 250;
1873
3069
  var MAX_LINE_CHARS = 500;
@@ -1900,18 +3096,19 @@ var grepTool = {
1900
3096
  } catch (err) {
1901
3097
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
1902
3098
  }
1903
- const root = path8.resolve(ctx.cwd, args.path ?? ".");
3099
+ const root = path10.resolve(ctx.cwd, args.path ?? ".");
1904
3100
  let files;
1905
3101
  try {
1906
3102
  const st = await stat4(root);
1907
3103
  if (st.isFile()) {
1908
- if (isSensitivePath(root)) {
3104
+ const resolved = await realpath(root).catch(() => null);
3105
+ if (isSensitivePath(root) || resolved !== null && isSensitivePath(resolved)) {
1909
3106
  return {
1910
3107
  content: `Refused to grep sensitive file: ${args.path ?? "."}`,
1911
3108
  isError: true
1912
3109
  };
1913
3110
  }
1914
- files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
3111
+ files = [{ abs: root, rel: path10.basename(root), mtimeMs: st.mtimeMs }];
1915
3112
  } else {
1916
3113
  const walked = await walkFiles(root, void 0, {
1917
3114
  ignore: createIgnorePredicate(ctx.cwd),
@@ -1977,13 +3174,13 @@ var grepTool = {
1977
3174
  };
1978
3175
 
1979
3176
  // src/tools/fs/read.ts
1980
- import { open, stat as stat5 } from "fs/promises";
1981
- import path9 from "path";
3177
+ import { open, realpath as realpath2, stat as stat5 } from "fs/promises";
3178
+ import path11 from "path";
1982
3179
  import { z as z6 } from "zod";
1983
3180
  var DEFAULT_LIMIT = 2e3;
1984
3181
  var MAX_CHARS = 100 * 1024;
1985
3182
  var MAX_READ_BYTES = 1024 * 1024;
1986
- var BINARY_PROBE_BYTES = 8192;
3183
+ var BINARY_PROBE_BYTES2 = 8192;
1987
3184
  function decodeUtf16be(buf) {
1988
3185
  const len = buf.length - buf.length % 2;
1989
3186
  const swapped = Buffer.allocUnsafe(len);
@@ -2000,7 +3197,7 @@ function decodeTextBuffer(buf) {
2000
3197
  if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
2001
3198
  return { text: decodeUtf16be(buf.subarray(2)) };
2002
3199
  }
2003
- if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
3200
+ if (buf.subarray(0, BINARY_PROBE_BYTES2).includes(0)) {
2004
3201
  return { binary: true };
2005
3202
  }
2006
3203
  return { text: buf.toString("utf8") };
@@ -2010,93 +3207,327 @@ var schema5 = z6.object({
2010
3207
  offset: z6.number().int().positive().optional().describe("1-based line number to start reading from"),
2011
3208
  limit: z6.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
2012
3209
  });
2013
- var readFileTool = {
2014
- name: "read_file",
2015
- description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default.",
3210
+ var READ_CACHE_MAX_ENTRIES = 200;
3211
+ function createReadFileTool() {
3212
+ const cache4 = /* @__PURE__ */ new Map();
3213
+ return {
3214
+ name: "read_file",
3215
+ description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default. Re-reading an unchanged file range returns a short note instead of the content.",
3216
+ permission: "read",
3217
+ parameters: schema5,
3218
+ reset: () => cache4.clear(),
3219
+ async execute(args, ctx) {
3220
+ const filePath = path11.resolve(ctx.cwd, args.path);
3221
+ const resolvedPath = await realpath2(filePath).catch(() => null);
3222
+ if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
3223
+ return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
3224
+ }
3225
+ const st = await stat5(filePath).catch(() => null);
3226
+ if (!st) {
3227
+ return { content: `File not found: ${args.path}`, isError: true };
3228
+ }
3229
+ if (st.isDirectory()) {
3230
+ return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
3231
+ }
3232
+ const start = args.offset ?? 1;
3233
+ const limit = args.limit ?? DEFAULT_LIMIT;
3234
+ const cacheKey = resolvedPath ?? filePath;
3235
+ const hit = cache4.get(cacheKey);
3236
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
3237
+ const requestedEnd = Math.min(hit.totalLines, start - 1 + limit);
3238
+ if (start >= hit.start && requestedEnd <= hit.end && start <= hit.totalLines) {
3239
+ return {
3240
+ content: `[unchanged since last read: ${args.path} \u2014 lines ${start}-${requestedEnd} of ${hit.totalLines} are already in context and the file has not changed]`
3241
+ };
3242
+ }
3243
+ }
3244
+ const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
3245
+ const fileCapped = st.size > MAX_READ_BYTES;
3246
+ const handle = await open(filePath, "r").catch(() => null);
3247
+ if (!handle) {
3248
+ return { content: `Failed to read ${args.path}`, isError: true };
3249
+ }
3250
+ let buf;
3251
+ try {
3252
+ const { bytesRead, buffer } = await handle.read(
3253
+ Buffer.alloc(bytesToRead),
3254
+ 0,
3255
+ bytesToRead,
3256
+ 0
3257
+ );
3258
+ buf = buffer.subarray(0, bytesRead);
3259
+ } finally {
3260
+ await handle.close().catch(() => {
3261
+ });
3262
+ }
3263
+ const decoded = decodeTextBuffer(buf);
3264
+ if ("binary" in decoded) {
3265
+ return { content: `Cannot read binary file: ${args.path}`, isError: true };
3266
+ }
3267
+ const lines = decoded.text.split("\n");
3268
+ const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
3269
+ if (start > lines.length) {
3270
+ return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
3271
+ }
3272
+ const end = Math.min(lines.length, start - 1 + limit);
3273
+ const out = [];
3274
+ let size = 0;
3275
+ let truncatedBySize = false;
3276
+ for (let i = start - 1; i < end; i++) {
3277
+ const line = (lines[i] ?? "").replace(/\r$/, "");
3278
+ const numbered = `${i + 1} ${line}`;
3279
+ if (size + numbered.length > MAX_CHARS) {
3280
+ truncatedBySize = true;
3281
+ break;
3282
+ }
3283
+ out.push(numbered);
3284
+ size += numbered.length;
3285
+ }
3286
+ const lastShown = start - 1 + out.length;
3287
+ if (truncatedBySize) {
3288
+ out.push(
3289
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
3290
+ );
3291
+ } else if (end < lines.length || fileCapped) {
3292
+ out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
3293
+ }
3294
+ if (out.length > 0) {
3295
+ if (cache4.size >= READ_CACHE_MAX_ENTRIES) cache4.clear();
3296
+ const prev = hit?.mtimeMs === st.mtimeMs && hit.size === st.size ? hit : void 0;
3297
+ cache4.set(cacheKey, {
3298
+ mtimeMs: st.mtimeMs,
3299
+ size: st.size,
3300
+ totalLines: lines.length,
3301
+ // Extend the remembered range when the new read overlaps or abuts
3302
+ // the previous one; disjoint ranges replace it.
3303
+ start: prev && start <= prev.end + 1 ? Math.min(prev.start, start) : start,
3304
+ end: prev && lastShown >= prev.start - 1 ? Math.max(prev.end, lastShown) : lastShown
3305
+ });
3306
+ }
3307
+ return { content: out.join("\n") };
3308
+ }
3309
+ };
3310
+ }
3311
+ var readFileTool = createReadFileTool();
3312
+
3313
+ // src/tools/fs/read-image.ts
3314
+ import { readFile as readFile5, realpath as realpath3, stat as stat6 } from "fs/promises";
3315
+ import path13 from "path";
3316
+ import { z as z7 } from "zod";
3317
+
3318
+ // src/core/image-resize.ts
3319
+ import { execFile as execFile2 } from "child_process";
3320
+ import { randomUUID as randomUUID2 } from "crypto";
3321
+ import { readFile as readFile4, unlink, writeFile as writeFile4 } from "fs/promises";
3322
+ import os4 from "os";
3323
+ import path12 from "path";
3324
+ import { promisify } from "util";
3325
+ var RESIZE_TIMEOUT_MS = 1e4;
3326
+ var execFileAsync = promisify(execFile2);
3327
+ function windowsResizeScript(inputFile, outputFile, maxDimension) {
3328
+ const input = inputFile.replace(/'/g, "''");
3329
+ const output = outputFile.replace(/'/g, "''");
3330
+ return [
3331
+ "Add-Type -AssemblyName System.Drawing",
3332
+ `$img = [System.Drawing.Image]::FromFile('${input}')`,
3333
+ `$scale = [Math]::Min(1.0, ${maxDimension} / [Math]::Max($img.Width, $img.Height))`,
3334
+ "$w = [int][Math]::Max(1, [Math]::Round($img.Width * $scale))",
3335
+ "$h = [int][Math]::Max(1, [Math]::Round($img.Height * $scale))",
3336
+ "$bmp = New-Object System.Drawing.Bitmap($w, $h)",
3337
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
3338
+ "$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
3339
+ "$g.DrawImage($img, 0, 0, $w, $h)",
3340
+ `$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
3341
+ "$g.Dispose(); $bmp.Dispose(); $img.Dispose()"
3342
+ ].join("; ");
3343
+ }
3344
+ function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools = {}) {
3345
+ if (platform === "win32") {
3346
+ return {
3347
+ command: "powershell.exe",
3348
+ args: [
3349
+ "-NoProfile",
3350
+ "-NonInteractive",
3351
+ "-Command",
3352
+ windowsResizeScript(inputFile, outputFile, maxDimension)
3353
+ ]
3354
+ };
3355
+ }
3356
+ if (platform === "darwin") {
3357
+ return {
3358
+ command: "sips",
3359
+ args: ["-Z", String(maxDimension), inputFile, "--out", outputFile]
3360
+ };
3361
+ }
3362
+ if (platform === "linux") {
3363
+ if (tools.hasConvert) {
3364
+ return {
3365
+ command: "convert",
3366
+ // -auto-orient keeps EXIF-rotated photos upright after the resize
3367
+ // strips their orientation metadata.
3368
+ args: [
3369
+ inputFile,
3370
+ "-auto-orient",
3371
+ "-resize",
3372
+ `${maxDimension}x${maxDimension}>`,
3373
+ outputFile
3374
+ ]
3375
+ };
3376
+ }
3377
+ if (tools.hasFfmpeg) {
3378
+ return {
3379
+ command: "ffmpeg",
3380
+ args: [
3381
+ "-y",
3382
+ "-i",
3383
+ inputFile,
3384
+ "-vf",
3385
+ `scale='if(gt(iw,ih),min(iw,${maxDimension}),-1)':'if(gt(iw,ih),-1,min(ih,${maxDimension}))'`,
3386
+ outputFile
3387
+ ]
3388
+ };
3389
+ }
3390
+ return null;
3391
+ }
3392
+ return null;
3393
+ }
3394
+ async function commandExists(command) {
3395
+ const checker = process.platform === "win32" ? "where" : "which";
3396
+ try {
3397
+ await execFileAsync(checker, [command], { timeout: RESIZE_TIMEOUT_MS });
3398
+ return true;
3399
+ } catch {
3400
+ return false;
3401
+ }
3402
+ }
3403
+ async function downsampleImageIfNeeded(image) {
3404
+ const buf = Buffer.from(image.data, "base64");
3405
+ const dims = probeImageDimensions(buf);
3406
+ if (!dims || Math.max(dims.width, dims.height) <= MAX_IMAGE_DIMENSION) {
3407
+ return { image, oversized: false, resized: false };
3408
+ }
3409
+ const inputFile = path12.join(os4.tmpdir(), `star-image-${randomUUID2()}.src`);
3410
+ const outputFile = path12.join(os4.tmpdir(), `star-image-${randomUUID2()}.png`);
3411
+ try {
3412
+ const [hasConvert, hasFfmpeg] = process.platform === "linux" ? await Promise.all([commandExists("convert"), commandExists("ffmpeg")]) : [false, false];
3413
+ const plan = resizeImagePlan(process.platform, inputFile, outputFile, MAX_IMAGE_DIMENSION, {
3414
+ hasConvert,
3415
+ hasFfmpeg
3416
+ });
3417
+ if (!plan) return { image, oversized: true, resized: false };
3418
+ await writeFile4(inputFile, buf);
3419
+ await execFileAsync(plan.command, plan.args, { timeout: RESIZE_TIMEOUT_MS });
3420
+ const out = await readFile4(outputFile).catch(() => null);
3421
+ if (!out || out.length === 0) return { image, oversized: true, resized: false };
3422
+ return {
3423
+ image: { path: image.path, mimeType: "image/png", data: out.toString("base64") },
3424
+ oversized: true,
3425
+ resized: true
3426
+ };
3427
+ } catch {
3428
+ return { image, oversized: true, resized: false };
3429
+ } finally {
3430
+ await unlink(inputFile).catch(() => {
3431
+ });
3432
+ await unlink(outputFile).catch(() => {
3433
+ });
3434
+ }
3435
+ }
3436
+
3437
+ // src/tools/fs/read-image.ts
3438
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3439
+ var IMAGE_MIME_TYPES = {
3440
+ ".png": "image/png",
3441
+ ".jpg": "image/jpeg",
3442
+ ".jpeg": "image/jpeg",
3443
+ ".gif": "image/gif",
3444
+ ".webp": "image/webp"
3445
+ };
3446
+ var schema6 = z7.object({
3447
+ path: z7.string().describe(
3448
+ "Image file path (png/jpg/jpeg/gif/webp), absolute or relative to the working directory"
3449
+ )
3450
+ });
3451
+ var readImageTool = {
3452
+ name: "read_image",
3453
+ description: "Read an image file (png, jpg, gif, webp) and show it to the model. Use this whenever the user references a screenshot, diagram, or picture file you need to see. Images larger than 2000px are downsampled automatically.",
2016
3454
  permission: "read",
2017
- parameters: schema5,
3455
+ parameters: schema6,
2018
3456
  async execute(args, ctx) {
2019
- const filePath = path9.resolve(ctx.cwd, args.path);
2020
- if (isSensitivePath(filePath)) {
3457
+ const filePath = path13.resolve(ctx.cwd, args.path);
3458
+ const resolvedPath = await realpath3(filePath).catch(() => null);
3459
+ if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
2021
3460
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
2022
3461
  }
2023
- const st = await stat5(filePath).catch(() => null);
2024
- if (!st) {
3462
+ const mimeType = IMAGE_MIME_TYPES[path13.extname(filePath).toLowerCase()];
3463
+ if (!mimeType) {
3464
+ return {
3465
+ content: `Not a supported image (png/jpg/jpeg/gif/webp): ${args.path}`,
3466
+ isError: true
3467
+ };
3468
+ }
3469
+ const st = await stat6(filePath).catch(() => null);
3470
+ if (!st || !st.isFile()) {
2025
3471
  return { content: `File not found: ${args.path}`, isError: true };
2026
3472
  }
2027
- if (st.isDirectory()) {
2028
- return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
3473
+ if (st.size > MAX_IMAGE_BYTES) {
3474
+ return {
3475
+ content: `Image too large (${Math.round(st.size / 1024)}KB > ${MAX_IMAGE_BYTES / 1024}KB): ${args.path}`,
3476
+ isError: true
3477
+ };
2029
3478
  }
2030
- const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
2031
- const fileCapped = st.size > MAX_READ_BYTES;
2032
- const handle = await open(filePath, "r").catch(() => null);
2033
- if (!handle) {
3479
+ const buf = await readFile5(filePath).catch(() => null);
3480
+ if (!buf || buf.length === 0) {
2034
3481
  return { content: `Failed to read ${args.path}`, isError: true };
2035
3482
  }
2036
- let buf;
2037
- try {
2038
- const { bytesRead, buffer } = await handle.read(Buffer.alloc(bytesToRead), 0, bytesToRead, 0);
2039
- buf = buffer.subarray(0, bytesRead);
2040
- } finally {
2041
- await handle.close().catch(() => {
2042
- });
2043
- }
2044
- const decoded = decodeTextBuffer(buf);
2045
- if ("binary" in decoded) {
2046
- return { content: `Cannot read binary file: ${args.path}`, isError: true };
2047
- }
2048
- const lines = decoded.text.split("\n");
2049
- const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
2050
- const start = args.offset ?? 1;
2051
- const limit = args.limit ?? DEFAULT_LIMIT;
2052
- if (start > lines.length) {
2053
- return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
2054
- }
2055
- const end = Math.min(lines.length, start - 1 + limit);
2056
- const out = [];
2057
- let size = 0;
2058
- let truncatedBySize = false;
2059
- for (let i = start - 1; i < end; i++) {
2060
- const line = (lines[i] ?? "").replace(/\r$/, "");
2061
- const numbered = `${i + 1} ${line}`;
2062
- if (size + numbered.length > MAX_CHARS) {
2063
- truncatedBySize = true;
2064
- break;
2065
- }
2066
- out.push(numbered);
2067
- size += numbered.length;
3483
+ if (!probeImageDimensions(buf)) {
3484
+ return {
3485
+ content: `File is not a valid image (unrecognized header): ${args.path}`,
3486
+ isError: true
3487
+ };
2068
3488
  }
2069
- const lastShown = start - 1 + out.length;
2070
- if (truncatedBySize) {
2071
- out.push(
2072
- `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
2073
- );
2074
- } else if (end < lines.length || fileCapped) {
2075
- out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
3489
+ const downsampled = await downsampleImageIfNeeded({
3490
+ path: args.path,
3491
+ mimeType,
3492
+ data: buf.toString("base64")
3493
+ });
3494
+ if (downsampled.oversized && !downsampled.resized) {
3495
+ return {
3496
+ content: `Image exceeds 2000px and could not be downsampled on this platform: ${args.path}`,
3497
+ isError: true
3498
+ };
2076
3499
  }
2077
- return { content: out.join("\n") };
3500
+ const image = downsampled.image;
3501
+ const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
3502
+ const sizeKB = Math.round(image.data.length / 1024);
3503
+ const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
3504
+ const resizedNote = downsampled.resized ? " (downsampled to fit 2000px)" : "";
3505
+ return {
3506
+ content: `Image loaded: ${args.path} (${desc}, ~${sizeKB}KB)${resizedNote}. The image is attached below \u2014 look at it now.`,
3507
+ images: [image]
3508
+ };
2078
3509
  }
2079
3510
  };
2080
3511
 
2081
3512
  // src/tools/fs/write.ts
2082
- import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
2083
- import path10 from "path";
2084
- import { z as z7 } from "zod";
2085
- var schema6 = z7.object({
2086
- path: z7.string().describe("File path, absolute or relative to the working directory"),
2087
- content: z7.string().describe("Full content to write to the file")
3513
+ import { mkdir as mkdir3, writeFile as writeFile5 } from "fs/promises";
3514
+ import path14 from "path";
3515
+ import { z as z8 } from "zod";
3516
+ var schema7 = z8.object({
3517
+ path: z8.string().describe("File path, absolute or relative to the working directory"),
3518
+ content: z8.string().describe("Full content to write to the file")
2088
3519
  });
2089
3520
  var writeFileTool = {
2090
3521
  name: "write_file",
2091
3522
  description: "Write content to a file, creating parent directories as needed. Overwrites existing files.",
2092
3523
  permission: "write",
2093
- parameters: schema6,
3524
+ parameters: schema7,
2094
3525
  async execute(args, ctx) {
2095
- const filePath = path10.resolve(ctx.cwd, args.path);
3526
+ const filePath = path14.resolve(ctx.cwd, args.path);
2096
3527
  const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
2097
3528
  try {
2098
- await mkdir3(path10.dirname(filePath), { recursive: true });
2099
- await writeFile3(filePath, args.content, "utf8");
3529
+ await mkdir3(path14.dirname(filePath), { recursive: true });
3530
+ await writeFile5(filePath, args.content, "utf8");
2100
3531
  } catch (err) {
2101
3532
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
2102
3533
  }
@@ -2105,8 +3536,159 @@ var writeFileTool = {
2105
3536
  }
2106
3537
  };
2107
3538
 
3539
+ // src/tools/screenshot.ts
3540
+ import { z as z9 } from "zod";
3541
+
3542
+ // src/core/screenshot.ts
3543
+ import { execFile as execFile3 } from "child_process";
3544
+ import { randomUUID as randomUUID3 } from "crypto";
3545
+ import { readFile as readFile6, unlink as unlink2 } from "fs/promises";
3546
+ import os5 from "os";
3547
+ import path15 from "path";
3548
+ import { promisify as promisify2 } from "util";
3549
+ var SCREENSHOT_TIMEOUT_MS = 2e4;
3550
+ var execFileAsync2 = promisify2(execFile3);
3551
+ function windowsScreenshotScript(outputFile) {
3552
+ const output = outputFile.replace(/'/g, "''");
3553
+ return [
3554
+ `try { Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(int v);' -Name Dpi -Namespace StarShot -ErrorAction Stop; [void][StarShot.Dpi]::SetProcessDpiAwarenessContext(-4) } catch { }`,
3555
+ "Add-Type -AssemblyName System.Windows.Forms,System.Drawing",
3556
+ "$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds",
3557
+ "$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height)",
3558
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
3559
+ "$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)",
3560
+ `$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
3561
+ "$g.Dispose(); $bmp.Dispose()"
3562
+ ].join("; ");
3563
+ }
3564
+ function screenshotPlan(platform, outputFile, env = process.env, tools = {}) {
3565
+ if (platform === "win32") {
3566
+ return {
3567
+ command: "powershell.exe",
3568
+ args: ["-NoProfile", "-NonInteractive", "-Command", windowsScreenshotScript(outputFile)]
3569
+ };
3570
+ }
3571
+ if (platform === "darwin") {
3572
+ return { command: "screencapture", args: ["-x", "-D", "1", outputFile] };
3573
+ }
3574
+ if (platform === "linux") {
3575
+ if (env.WAYLAND_DISPLAY) {
3576
+ if (tools.grim) return { command: "grim", args: [outputFile] };
3577
+ if (tools["gnome-screenshot"]) {
3578
+ return { command: "gnome-screenshot", args: ["-f", outputFile] };
3579
+ }
3580
+ if (tools.spectacle) {
3581
+ return { command: "spectacle", args: ["-b", "-n", "-o", outputFile] };
3582
+ }
3583
+ return null;
3584
+ }
3585
+ if (env.DISPLAY) {
3586
+ if (tools.import) return { command: "import", args: ["-window", "root", outputFile] };
3587
+ if (tools.scrot) return { command: "scrot", args: ["--overwrite", outputFile] };
3588
+ if (tools["gnome-screenshot"]) {
3589
+ return { command: "gnome-screenshot", args: ["-f", outputFile] };
3590
+ }
3591
+ if (tools.spectacle) {
3592
+ return { command: "spectacle", args: ["-b", "-n", "-o", outputFile] };
3593
+ }
3594
+ return null;
3595
+ }
3596
+ return null;
3597
+ }
3598
+ return null;
3599
+ }
3600
+ function noScreenshotToolMessage(platform, env = process.env) {
3601
+ if (platform === "linux") {
3602
+ if (!env.WAYLAND_DISPLAY && !env.DISPLAY) {
3603
+ return "no display server (neither DISPLAY nor WAYLAND_DISPLAY is set) \u2014 this session is headless (e.g. SSH), there is no screen to capture";
3604
+ }
3605
+ return "no screenshot tool found \u2014 install one of: grim or spectacle (Wayland), scrot or ImageMagick's import (X11), or gnome-screenshot";
3606
+ }
3607
+ return `screenshots are not supported on this platform (${platform})`;
3608
+ }
3609
+ async function commandExists2(command) {
3610
+ const checker = process.platform === "win32" ? "where" : "which";
3611
+ try {
3612
+ await execFileAsync2(checker, [command], { timeout: SCREENSHOT_TIMEOUT_MS });
3613
+ return true;
3614
+ } catch {
3615
+ return false;
3616
+ }
3617
+ }
3618
+ var LINUX_TOOL_NAMES = ["grim", "gnome-screenshot", "spectacle", "import", "scrot"];
3619
+ async function captureScreenshot() {
3620
+ const outputFile = path15.join(os5.tmpdir(), `star-screenshot-${randomUUID3()}.png`);
3621
+ try {
3622
+ const tools = {};
3623
+ if (process.platform === "linux") {
3624
+ const found = await Promise.all(LINUX_TOOL_NAMES.map(commandExists2));
3625
+ for (const [i, name] of LINUX_TOOL_NAMES.entries()) {
3626
+ tools[name] = found[i] ?? false;
3627
+ }
3628
+ }
3629
+ const plan = screenshotPlan(process.platform, outputFile, process.env, tools);
3630
+ if (!plan) {
3631
+ return { ok: false, error: noScreenshotToolMessage(process.platform) };
3632
+ }
3633
+ try {
3634
+ await execFileAsync2(plan.command, plan.args, { timeout: SCREENSHOT_TIMEOUT_MS });
3635
+ } catch (err) {
3636
+ const detail = err instanceof Error && err.message ? ` (${err.message})` : "";
3637
+ return { ok: false, error: `${plan.command} failed to capture the screen${detail}` };
3638
+ }
3639
+ const buf = await readFile6(outputFile).catch(() => null);
3640
+ if (!buf || buf.length === 0) {
3641
+ return { ok: false, error: `${plan.command} produced no image file` };
3642
+ }
3643
+ if (!probeImageDimensions(buf)) {
3644
+ return { ok: false, error: `${plan.command} produced an unrecognized image file` };
3645
+ }
3646
+ const downsampled = await downsampleImageIfNeeded({
3647
+ path: "screen",
3648
+ mimeType: "image/png",
3649
+ data: buf.toString("base64")
3650
+ });
3651
+ if (downsampled.oversized && !downsampled.resized) {
3652
+ return {
3653
+ ok: false,
3654
+ error: "screenshot exceeds 2000px and could not be downsampled on this platform"
3655
+ };
3656
+ }
3657
+ return { ok: true, image: downsampled.image };
3658
+ } finally {
3659
+ await unlink2(outputFile).catch(() => {
3660
+ });
3661
+ }
3662
+ }
3663
+
3664
+ // src/tools/screenshot.ts
3665
+ var schema8 = z9.object({});
3666
+ function createScreenshotTool(capture = captureScreenshot) {
3667
+ return {
3668
+ name: "screenshot",
3669
+ description: "Capture a screenshot of the primary display and see it. Use this to check the actual visual result of a running program (web page, GUI) \u2014 e.g. after starting a dev server \u2014 instead of inferring from code. The capture shows exactly what is visible on screen, including overlapping windows: content hidden behind other windows cannot be seen, so if a specific window matters ask the user to bring it to the front first. Note the capture may include anything else currently on screen.",
3670
+ permission: "read",
3671
+ parameters: schema8,
3672
+ async execute() {
3673
+ const result = await capture();
3674
+ if (!result.ok) {
3675
+ return { content: `Screenshot failed: ${result.error}`, isError: true };
3676
+ }
3677
+ const image = result.image;
3678
+ const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
3679
+ const sizeKB = Math.round(image.data.length / 1024);
3680
+ const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
3681
+ return {
3682
+ content: `Screenshot captured (${desc}, ~${sizeKB}KB). The image is attached below \u2014 look at it now.`,
3683
+ images: [image]
3684
+ };
3685
+ }
3686
+ };
3687
+ }
3688
+ var screenshotTool = createScreenshotTool();
3689
+
2108
3690
  // src/tools/tasks.ts
2109
- import { z as z8 } from "zod";
3691
+ import { z as z10 } from "zod";
2110
3692
 
2111
3693
  // src/agent/agent-tasks.ts
2112
3694
  import { EventEmitter as EventEmitter2 } from "events";
@@ -2277,8 +3859,8 @@ function formatTaskFinished(task) {
2277
3859
  }
2278
3860
 
2279
3861
  // src/tools/tasks.ts
2280
- var idSchema = z8.object({
2281
- id: z8.string().describe("Task id, e.g. task-1 or agent-1")
3862
+ var idSchema = z10.object({
3863
+ id: z10.string().describe("Task id, e.g. task-1 or agent-1")
2282
3864
  });
2283
3865
  function truncateOutput(s) {
2284
3866
  if (s.length <= MAX_OUTPUT) return s;
@@ -2295,7 +3877,7 @@ var taskListTool = {
2295
3877
  name: "task_list",
2296
3878
  description: "List background tasks (shell commands and subagents) with status and runtime.",
2297
3879
  permission: "read",
2298
- parameters: z8.object({}),
3880
+ parameters: z10.object({}),
2299
3881
  execute() {
2300
3882
  const shell = formatTaskList(defaultTaskManager.list());
2301
3883
  const agents = defaultAgentTasks.list();
@@ -2354,20 +3936,20 @@ var taskKillTool = {
2354
3936
  };
2355
3937
 
2356
3938
  // src/tools/todo.ts
2357
- import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
2358
- import path11 from "path";
2359
- import { z as z9 } from "zod";
2360
- var todoItemSchema = z9.object({
2361
- id: z9.number().int(),
2362
- title: z9.string(),
2363
- status: z9.enum(["pending", "in_progress", "done"])
3939
+ import { mkdir as mkdir4, readFile as readFile7, rename, rm as rm3, writeFile as writeFile6 } from "fs/promises";
3940
+ import path16 from "path";
3941
+ import { z as z11 } from "zod";
3942
+ var todoItemSchema = z11.object({
3943
+ id: z11.number().int(),
3944
+ title: z11.string(),
3945
+ status: z11.enum(["pending", "in_progress", "done"])
2364
3946
  });
2365
- var writeSchema = z9.object({
2366
- todos: z9.array(todoItemSchema).describe("The full todo list, replacing the current one")
3947
+ var writeSchema = z11.object({
3948
+ todos: z11.array(todoItemSchema).describe("The full todo list, replacing the current one")
2367
3949
  });
2368
- var readSchema = z9.object({});
3950
+ var readSchema = z11.object({});
2369
3951
  function fileFor(cwd) {
2370
- return path11.join(cwd, ".star", "todos.json");
3952
+ return path16.join(cwd, ".star", "todos.json");
2371
3953
  }
2372
3954
  var persistGuard = () => true;
2373
3955
  function setTodoPersistGuard(guard) {
@@ -2387,7 +3969,7 @@ var TodoStore = class {
2387
3969
  async load(cwd) {
2388
3970
  let raw;
2389
3971
  try {
2390
- raw = await readFile4(fileFor(cwd), "utf8");
3972
+ raw = await readFile7(fileFor(cwd), "utf8");
2391
3973
  } catch {
2392
3974
  return;
2393
3975
  }
@@ -2400,15 +3982,22 @@ var TodoStore = class {
2400
3982
  }
2401
3983
  }
2402
3984
  // Atomic write (tmp + rename): a crash or failed save can never leave a
2403
- // truncated todos.json behind.
3985
+ // truncated todos.json behind. An empty or fully completed list has no
3986
+ // resume value — rehydrating it would only restore finished work — so the
3987
+ // file is deleted instead of persisted.
2404
3988
  async save(cwd) {
2405
3989
  if (!persistGuard()) {
2406
3990
  return;
2407
3991
  }
2408
- await mkdir4(path11.join(cwd, ".star"), { recursive: true });
3992
+ const items = this.list();
2409
3993
  const file = fileFor(cwd);
3994
+ if (items.length === 0 || items.every((it) => it.status === "done")) {
3995
+ await rm3(file, { force: true });
3996
+ return;
3997
+ }
3998
+ await mkdir4(path16.join(cwd, ".star"), { recursive: true });
2410
3999
  const tmp = `${file}.tmp`;
2411
- await writeFile4(tmp, `${JSON.stringify(this.list(), null, 2)}
4000
+ await writeFile6(tmp, `${JSON.stringify(items, null, 2)}
2412
4001
  `);
2413
4002
  await rename(tmp, file);
2414
4003
  }
@@ -2481,13 +4070,13 @@ function createTodoTools(store = defaultStore) {
2481
4070
  }
2482
4071
 
2483
4072
  // src/tools/web/fetch.ts
2484
- import { z as z10 } from "zod";
4073
+ import { z as z12 } from "zod";
2485
4074
  var DEFAULT_MAX_CHARS = 2e4;
2486
4075
  var MAX_BYTES = 2 * 1024 * 1024;
2487
4076
  var TIMEOUT_MS = 3e4;
2488
- var schema7 = z10.object({
2489
- url: z10.string().describe("The http(s) URL to fetch"),
2490
- maxChars: z10.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
4077
+ var schema9 = z12.object({
4078
+ url: z12.string().describe("The http(s) URL to fetch"),
4079
+ maxChars: z12.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
2491
4080
  });
2492
4081
  function safeCodePoint(n, raw) {
2493
4082
  if (!Number.isInteger(n) || n < 0 || n > 1114111) {
@@ -2558,7 +4147,7 @@ var webFetchTool = {
2558
4147
  name: "web_fetch",
2559
4148
  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.",
2560
4149
  permission: "read",
2561
- parameters: schema7,
4150
+ parameters: schema9,
2562
4151
  async execute(args, ctx) {
2563
4152
  let url;
2564
4153
  try {
@@ -2621,15 +4210,15 @@ var webFetchTool = {
2621
4210
  };
2622
4211
 
2623
4212
  // src/tools/web/search.ts
2624
- import { z as z11 } from "zod";
4213
+ import { z as z13 } from "zod";
2625
4214
  var TIMEOUT_MS2 = 15e3;
2626
4215
  var DEFAULT_MAX_RESULTS = 5;
2627
4216
  var MAX_RESULTS_LIMIT = 10;
2628
4217
  var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
2629
4218
  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";
2630
- var schema8 = z11.object({
2631
- query: z11.string().min(1).describe("The search query"),
2632
- maxResults: z11.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
4219
+ var schema10 = z13.object({
4220
+ query: z13.string().min(1).describe("The search query"),
4221
+ maxResults: z13.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
2633
4222
  `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
2634
4223
  )
2635
4224
  });
@@ -2680,7 +4269,7 @@ var webSearchTool = {
2680
4269
  name: "web_search",
2681
4270
  description: "Search the web using DuckDuckGo and return a numbered list of results with title, URL and snippet. No API key required. Use web_fetch to read a result's full page.",
2682
4271
  permission: "read",
2683
- parameters: schema8,
4272
+ parameters: schema10,
2684
4273
  async execute(args, ctx) {
2685
4274
  const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
2686
4275
  const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
@@ -2742,7 +4331,11 @@ var ToolRegistry = class {
2742
4331
  // their own instance so a child's todo_write cannot clobber the parent list.
2743
4332
  constructor(todoStore) {
2744
4333
  for (const tool of [
2745
- readFileTool,
4334
+ // Fresh per registry (i.e. per agent loop): the unchanged-since-last-
4335
+ // read cache must not leak across loops, whose contexts differ.
4336
+ createReadFileTool(),
4337
+ readImageTool,
4338
+ screenshotTool,
2746
4339
  writeFileTool,
2747
4340
  editFileTool,
2748
4341
  globTool,
@@ -2761,6 +4354,12 @@ var ToolRegistry = class {
2761
4354
  register(tool) {
2762
4355
  this.tools.set(tool.name, tool);
2763
4356
  }
4357
+ // Drops per-session volatile tool state after a wholesale history rewrite
4358
+ // (compaction, /undo, resume): anything a tool remembers as "already sent
4359
+ // to the model" may no longer be in the context.
4360
+ resetVolatileState() {
4361
+ for (const tool of this.tools.values()) tool.reset?.();
4362
+ }
2764
4363
  get(name) {
2765
4364
  return this.tools.get(name);
2766
4365
  }
@@ -2774,12 +4373,12 @@ var ToolRegistry = class {
2774
4373
 
2775
4374
  // src/agent/project-memory.ts
2776
4375
  import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
2777
- import path12 from "path";
4376
+ import path17 from "path";
2778
4377
  var PROJECT_MEMORY_FILE = "AGENTS.md";
2779
4378
  var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
2780
4379
  var cache = /* @__PURE__ */ new Map();
2781
4380
  function readProjectMemory(cwd) {
2782
- const file = path12.join(cwd, PROJECT_MEMORY_FILE);
4381
+ const file = path17.join(cwd, PROJECT_MEMORY_FILE);
2783
4382
  let mtimeMs;
2784
4383
  try {
2785
4384
  mtimeMs = statSync2(file).mtimeMs;
@@ -2809,8 +4408,8 @@ ${body}`;
2809
4408
 
2810
4409
  // src/agent/skills.ts
2811
4410
  import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
2812
- import path13 from "path";
2813
- import { z as z12 } from "zod";
4411
+ import path18 from "path";
4412
+ import { z as z14 } from "zod";
2814
4413
  var SKILL_FILE = "SKILL.md";
2815
4414
  var SKILL_MAX_CHARS = 32 * 1024;
2816
4415
  var NAME_PATTERN = /^[a-z0-9-]+$/;
@@ -2840,7 +4439,7 @@ function parseSkillFile(filePath, dirName, scope) {
2840
4439
  const name = parsed?.meta.name || dirName;
2841
4440
  const description = parsed?.meta.description;
2842
4441
  if (parsed && NAME_PATTERN.test(name) && description) {
2843
- skill = { name, description, filePath, dir: path13.dirname(filePath), scope };
4442
+ skill = { name, description, filePath, dir: path18.dirname(filePath), scope };
2844
4443
  }
2845
4444
  } catch {
2846
4445
  skill = null;
@@ -2857,14 +4456,14 @@ function scanDir(base, scope, into) {
2857
4456
  }
2858
4457
  for (const entry of entries.sort()) {
2859
4458
  if (!NAME_PATTERN.test(entry)) continue;
2860
- const parsed = parseSkillFile(path13.join(base, entry, SKILL_FILE), entry, scope);
4459
+ const parsed = parseSkillFile(path18.join(base, entry, SKILL_FILE), entry, scope);
2861
4460
  if (parsed) into.set(parsed.name, parsed);
2862
4461
  }
2863
4462
  }
2864
4463
  function discoverSkills(cwd, home = starHome()) {
2865
4464
  const byName = /* @__PURE__ */ new Map();
2866
- scanDir(path13.join(home, "skills"), "user", byName);
2867
- scanDir(path13.join(cwd, ".star", "skills"), "project", byName);
4465
+ scanDir(path18.join(home, "skills"), "user", byName);
4466
+ scanDir(path18.join(cwd, ".star", "skills"), "project", byName);
2868
4467
  return [...byName.values()];
2869
4468
  }
2870
4469
  function formatSkillsBlock(skills) {
@@ -2895,8 +4494,8 @@ function createSkillTool(deps) {
2895
4494
  name: "skill",
2896
4495
  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.",
2897
4496
  permission: "read",
2898
- parameters: z12.object({
2899
- name: z12.string().describe("Name of the skill to load, as listed in the system prompt.")
4497
+ parameters: z14.object({
4498
+ name: z14.string().describe("Name of the skill to load, as listed in the system prompt.")
2900
4499
  }),
2901
4500
  async execute(args) {
2902
4501
  const skills = discoverSkills(deps.cwd, deps.home);
@@ -2926,7 +4525,7 @@ function createSkillTool(deps) {
2926
4525
  }
2927
4526
 
2928
4527
  // src/agent/subagent.ts
2929
- import { z as z13 } from "zod";
4528
+ import { z as z15 } from "zod";
2930
4529
 
2931
4530
  // src/tools/index.ts
2932
4531
  function createDefaultRegistry(todoStore) {
@@ -2937,8 +4536,37 @@ function createDefaultRegistry(todoStore) {
2937
4536
  var MAX_SUBAGENT_DEPTH = 1;
2938
4537
  var MAX_REPORT_CHARS = 2e4;
2939
4538
  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.";
4539
+ function convertUsagePricing(usage, from, to, config) {
4540
+ if (from === to) return usage;
4541
+ const fromCfg = config.models.find((m) => m.model === from.modelId);
4542
+ const toCfg = config.models.find((m) => m.model === to.modelId);
4543
+ if (fromCfg?.promptPrice === void 0 || fromCfg.completionPrice === void 0 || toCfg?.promptPrice === void 0 || toCfg.completionPrice === void 0 || toCfg.promptPrice <= 0 || toCfg.completionPrice <= 0) {
4544
+ return usage;
4545
+ }
4546
+ const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
4547
+ const promptUsd = (usage.promptTokens - cached) * fromCfg.promptPrice;
4548
+ const cachedUsd = cached * (fromCfg.cacheReadPrice ?? fromCfg.promptPrice);
4549
+ const cacheReadUsd = (usage.cacheReadInputTokens ?? 0) * (fromCfg.cacheReadPrice ?? 0);
4550
+ const completionUsd = usage.completionTokens * fromCfg.completionPrice;
4551
+ const scaledCached = cachedUsd / (toCfg.cacheReadPrice ?? toCfg.promptPrice);
4552
+ const promptTokens = Math.round(promptUsd / toCfg.promptPrice + scaledCached);
4553
+ const cachedPromptTokens = Math.round(scaledCached);
4554
+ const toCacheReadPrice = toCfg.cacheReadPrice !== void 0 && toCfg.cacheReadPrice > 0 ? toCfg.cacheReadPrice : void 0;
4555
+ const completionTokens = Math.round(
4556
+ completionUsd / toCfg.completionPrice + (toCacheReadPrice === void 0 ? cacheReadUsd / toCfg.completionPrice : 0)
4557
+ );
4558
+ const cacheReadInputTokens = toCacheReadPrice === void 0 ? 0 : Math.round(cacheReadUsd / toCacheReadPrice);
4559
+ const result = {
4560
+ promptTokens,
4561
+ completionTokens,
4562
+ totalTokens: promptTokens + completionTokens
4563
+ };
4564
+ if (cachedPromptTokens > 0) result.cachedPromptTokens = cachedPromptTokens;
4565
+ if (cacheReadInputTokens > 0) result.cacheReadInputTokens = cacheReadInputTokens;
4566
+ return result;
4567
+ }
2940
4568
  async function runSubagent(deps, args, cwd, signal) {
2941
- const { AgentLoop: AgentLoop2 } = await import("./loop-XY3TE5NN.js");
4569
+ const { AgentLoop: AgentLoop2 } = await import("./loop-2MOFLXTG.js");
2942
4570
  const child = new AgentLoop2({
2943
4571
  model: deps.model,
2944
4572
  registry: createDefaultRegistry(),
@@ -2948,12 +4576,16 @@ async function runSubagent(deps, args, cwd, signal) {
2948
4576
 
2949
4577
  ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2950
4578
  providerMetadata: deps.providerMetadata,
4579
+ temperature: deps.temperature,
4580
+ streamIdleTimeoutSec: deps.streamIdleTimeoutSec,
4581
+ streamFirstChunkTimeoutSec: deps.streamFirstChunkTimeoutSec,
2951
4582
  subagentDepth: deps.depth + 1
2952
4583
  });
2953
4584
  const confirmHandler = deps.getConfirmHandler?.();
2954
4585
  if (confirmHandler) child.confirmHandler = confirmHandler;
2955
4586
  let toolCalls = 0;
2956
4587
  let error = null;
4588
+ const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
2957
4589
  try {
2958
4590
  for await (const event of child.stream(args.prompt, signal)) {
2959
4591
  if (event.type === "tool-result") {
@@ -2961,10 +4593,18 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2961
4593
  } else if (event.type === "error") {
2962
4594
  error = event.error.message;
2963
4595
  break;
4596
+ } else if (event.type === "finish" && event.usage) {
4597
+ usage.promptTokens += event.usage.promptTokens;
4598
+ usage.completionTokens += event.usage.completionTokens;
4599
+ usage.totalTokens += event.usage.totalTokens;
4600
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + (event.usage.cachedPromptTokens ?? 0);
4601
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + (event.usage.cacheReadInputTokens ?? 0);
2964
4602
  }
2965
4603
  }
2966
4604
  } catch (e) {
2967
4605
  error = e instanceof Error ? e.message : String(e);
4606
+ } finally {
4607
+ if (usage.totalTokens > 0) deps.onUsage?.(usage, deps.model);
2968
4608
  }
2969
4609
  if (signal.aborted) {
2970
4610
  throw new Error("Tool execution aborted.");
@@ -2997,10 +4637,10 @@ function createSubagentTool(deps) {
2997
4637
  name: "subagent",
2998
4638
  description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation. Set run_in_background to start it without blocking: independent subtasks then run concurrently and their reports are delivered to you automatically when they finish (also inspectable via task_list/task_output/task_kill).",
2999
4639
  permission: "exec",
3000
- parameters: z13.object({
3001
- prompt: z13.string().describe("Complete, self-contained instructions for the subagent."),
3002
- description: z13.string().optional().describe("Short label for the subtask, shown in the result header."),
3003
- run_in_background: z13.boolean().optional().describe(
4640
+ parameters: z15.object({
4641
+ prompt: z15.string().describe("Complete, self-contained instructions for the subagent."),
4642
+ description: z15.string().optional().describe("Short label for the subtask, shown in the result header."),
4643
+ run_in_background: z15.boolean().optional().describe(
3004
4644
  "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
3005
4645
  )
3006
4646
  }),
@@ -3026,14 +4666,14 @@ function createSubagentTool(deps) {
3026
4666
  }
3027
4667
 
3028
4668
  // src/agent/user-memory.ts
3029
- import { appendFileSync, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
3030
- import path14 from "path";
3031
- import { z as z14 } from "zod";
4669
+ import { appendFileSync as appendFileSync2, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
4670
+ import path19 from "path";
4671
+ import { z as z16 } from "zod";
3032
4672
  var USER_MEMORY_MAX_CHARS = 32 * 1024;
3033
4673
  var REMEMBER_TEXT_MAX_CHARS = 500;
3034
4674
  var cache3 = /* @__PURE__ */ new Map();
3035
4675
  function readUserMemory(home = starHome()) {
3036
- const file = path14.join(home, "MEMORY.md");
4676
+ const file = path19.join(home, "MEMORY.md");
3037
4677
  let mtimeMs;
3038
4678
  try {
3039
4679
  mtimeMs = statSync4(file).mtimeMs;
@@ -3065,7 +4705,7 @@ function readUserMemory(home = starHome()) {
3065
4705
  }
3066
4706
  function appendUserMemory(text, home = starHome()) {
3067
4707
  mkdirSync(home, { recursive: true });
3068
- appendFileSync(path14.join(home, "MEMORY.md"), `- ${text}
4708
+ appendFileSync2(path19.join(home, "MEMORY.md"), `- ${text}
3069
4709
  `, "utf8");
3070
4710
  }
3071
4711
  function createRememberTool(deps = {}) {
@@ -3073,8 +4713,8 @@ function createRememberTool(deps = {}) {
3073
4713
  name: "remember",
3074
4714
  description: `Save a note to the user's long-term memory (${userMemoryPath()}), injected into every future session. ONLY use when the user explicitly asks to remember or save something. Never use autonomously.`,
3075
4715
  permission: "write",
3076
- parameters: z14.object({
3077
- text: z14.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
4716
+ parameters: z16.object({
4717
+ text: z16.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
3078
4718
  }),
3079
4719
  async execute(args) {
3080
4720
  appendUserMemory(args.text, deps.home);
@@ -3085,9 +4725,10 @@ function createRememberTool(deps = {}) {
3085
4725
 
3086
4726
  // src/agent/loop.ts
3087
4727
  var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
3088
- var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?|I will|I'll|I am going to|I'm going to|I shall|let me|next,? I|I will now)/i;
4728
+ var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?(?!知道)|I will|I'll|I am going to|I'm going to|I shall|\blet me(?!\s+know\b)|next,? I|I will now)/i;
3089
4729
  var AUTO_CONTINUE_NUDGE = "[auto-continue] You ended your turn with words instead of actions. Do not describe or restate the plan \u2014 continue the task NOW by calling tools. Reply with text only if the task is already fully complete.";
3090
4730
  var EMPTY_REPLY_NUDGE = "[auto-continue] Your previous reply was empty \u2014 no text and no tool calls. If the task is fully complete, reply with one short confirmation; otherwise continue NOW by calling tools.";
4731
+ var TRUNCATED_CONTINUE_NUDGE = "[auto-continue] Your previous reply was cut off mid-stream by a timeout \u2014 it is incomplete. Resume EXACTLY where you stopped: do not repeat or restate what you already wrote; continue with the next tool call or the remainder of the text.";
3091
4732
  function isFilteredEmptyFinish(finishReason) {
3092
4733
  return finishReason === "content-filter";
3093
4734
  }
@@ -3098,7 +4739,25 @@ function finalNudgeWarning(autoContinues, maxAutoContinues) {
3098
4739
  return autoContinues >= maxAutoContinues ? " This is the final automatic continuation \u2014 if your next reply again has no tool calls, nudging stops and the turn is handed back to the user. Finish the work now or summarize what remains." : "";
3099
4740
  }
3100
4741
  var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
3101
- async function checkTaskComplete(model, request, finalReply, signal) {
4742
+ function attachTurnContext(messages, context) {
4743
+ if (!context) return messages;
4744
+ for (let i = messages.length - 1; i >= 0; i--) {
4745
+ const message = messages[i];
4746
+ if (message?.role !== "user") continue;
4747
+ const reminder = {
4748
+ type: "text",
4749
+ text: `<system-reminder>
4750
+ ${context}
4751
+ </system-reminder>`
4752
+ };
4753
+ const content = typeof message.content === "string" ? message.content ? [reminder, { type: "text", text: message.content }] : [reminder] : [reminder, ...message.content];
4754
+ const copy = messages.slice();
4755
+ copy[i] = { ...message, content };
4756
+ return copy;
4757
+ }
4758
+ return messages;
4759
+ }
4760
+ async function checkTaskComplete(model, request, finalReply, signal, temperature) {
3102
4761
  const controller = new AbortController();
3103
4762
  const timer = setTimeout(() => controller.abort(), COMPLETION_CHECK_TIMEOUT_MS);
3104
4763
  const onAbort = () => controller.abort();
@@ -3107,6 +4766,7 @@ async function checkTaskComplete(model, request, finalReply, signal) {
3107
4766
  const { text } = await generateText3({
3108
4767
  model,
3109
4768
  maxTokens: 8,
4769
+ temperature,
3110
4770
  abortSignal: controller.signal,
3111
4771
  prompt: [
3112
4772
  "An AI coding agent was given this task by the user:",
@@ -3184,9 +4844,19 @@ var AgentLoop = class {
3184
4844
  // consumes it as the redo label right after retractLastTurn sets it.
3185
4845
  lastUndoneLabel = "";
3186
4846
  // Doom-loop guard: signature of the most recent tool call and how many
3187
- // times in a row it has repeated. Any different call resets the count.
4847
+ // times in a row it has repeated. Any different call resets the count, and
4848
+ // each new user turn restarts the streak.
3188
4849
  lastToolSignature = null;
3189
4850
  repeatedToolCalls = 0;
4851
+ // Token usage subagents consumed, waiting to fold into this loop's next
4852
+ // finish event with usage: sync children report when their tool call
4853
+ // settles, background ones whenever they finish (drained or not).
4854
+ // Consumers meter only root-loop finish events, so folding here bills
4855
+ // subagent spend with zero consumer changes — /cost, /usage and
4856
+ // sessionBudgetUsd all cover it. A background child still running when the
4857
+ // session's last finish has passed never reports (best effort, noted in
4858
+ // agent-tasks cleanup paths).
4859
+ subagentUsage = null;
3190
4860
  // Turn context of the in-flight stream() turn, stamped onto every tool
3191
4861
  // execution so file snapshots attribute root changes to a turn and mark
3192
4862
  // subagent changes as excluded from turn-level retraction.
@@ -3194,6 +4864,16 @@ var AgentLoop = class {
3194
4864
  activeTurnUserIndex = -1;
3195
4865
  titleScheduled = false;
3196
4866
  titleInput;
4867
+ // Rendered git context for the in-flight turn (null when unavailable);
4868
+ // snapshotted at turn start so every step of the turn sends the same block.
4869
+ turnGitContext = null;
4870
+ // Lazily resolved auxiliary model (config.smallModel) for cheap side calls
4871
+ // — compaction summaries, session titles, completion checks. Undefined =
4872
+ // use the main model; a resolution failure is surfaced once as a notice.
4873
+ auxModelResolved = false;
4874
+ auxModelValue;
4875
+ auxTemperatureValue;
4876
+ auxModelError = null;
3197
4877
  confirmHandler;
3198
4878
  // Hook failures never block the turn (except an explicit PreToolUse block);
3199
4879
  // their stderr surfaces through this callback (REPL system message / stderr
@@ -3226,8 +4906,12 @@ var AgentLoop = class {
3226
4906
  cwd: opts.cwd,
3227
4907
  system: opts.system,
3228
4908
  providerMetadata: opts.providerMetadata,
4909
+ temperature: opts.temperature,
4910
+ streamIdleTimeoutSec: opts.streamIdleTimeoutSec,
4911
+ streamFirstChunkTimeoutSec: opts.streamFirstChunkTimeoutSec,
3229
4912
  depth,
3230
- getConfirmHandler: () => this.confirmHandler
4913
+ getConfirmHandler: () => this.confirmHandler,
4914
+ onUsage: (usage, childModel) => this.addSubagentUsage(usage, childModel)
3231
4915
  })
3232
4916
  );
3233
4917
  }
@@ -3235,6 +4919,29 @@ var AgentLoop = class {
3235
4919
  getMessages() {
3236
4920
  return this.messages;
3237
4921
  }
4922
+ // Auxiliary model for cheap side calls: config.smallModel, resolved once.
4923
+ // An unresolvable name/key degrades to the main model instead of breaking
4924
+ // turns; the failure rides out once as a notice at the next turn start.
4925
+ resolveAuxModel() {
4926
+ if (this.auxModelResolved) return;
4927
+ this.auxModelResolved = true;
4928
+ const name = this.opts.config.smallModel;
4929
+ if (!name) return;
4930
+ try {
4931
+ this.auxModelValue = createModel(this.opts.config, name);
4932
+ this.auxTemperatureValue = resolveModelConfig(this.opts.config, name).temperature ?? this.opts.temperature;
4933
+ } catch (error) {
4934
+ this.auxModelError = `smallModel "${name}" could not be resolved (${error instanceof Error ? error.message : String(error)}); using the main model for auxiliary calls.`;
4935
+ }
4936
+ }
4937
+ getAuxModel() {
4938
+ this.resolveAuxModel();
4939
+ return this.auxModelValue ?? this.opts.model;
4940
+ }
4941
+ getAuxTemperature() {
4942
+ this.resolveAuxModel();
4943
+ return this.auxModelValue ? this.auxTemperatureValue : this.opts.temperature;
4944
+ }
3238
4945
  // Rebinds the per-call provider metadata (e.g. after the reasoning effort
3239
4946
  // for the current model changed via /model) without recreating the loop.
3240
4947
  setProviderMetadata(metadata) {
@@ -3275,6 +4982,7 @@ var AgentLoop = class {
3275
4982
  this.messages = reconcileToolCalls(messages);
3276
4983
  this.turnMarkers = [];
3277
4984
  this.redoStack = [];
4985
+ this.opts.registry?.resetVolatileState();
3278
4986
  }
3279
4987
  // Read-only counterpart of retractLastTurn: how many messages /undo would
3280
4988
  // drop, which snapshot turn's file changes it would revert (undefined =
@@ -3306,6 +5014,7 @@ var AgentLoop = class {
3306
5014
  this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
3307
5015
  this.messages = result.messages;
3308
5016
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
5017
+ this.opts.registry?.resetVolatileState();
3309
5018
  const last = this.turnMarkers[this.turnMarkers.length - 1];
3310
5019
  let turn;
3311
5020
  let tree;
@@ -3377,14 +5086,19 @@ var AgentLoop = class {
3377
5086
  }
3378
5087
  // Truncates the history back to the given message index (the user message
3379
5088
  // at that index and everything after it is dropped) and persists the
3380
- // trimmed history, so /rewind stays consistent across resume.
5089
+ // trimmed history, so /rewind stays consistent across resume. The redo
5090
+ // stack dies with the rewound context: its entries describe pre-/undo
5091
+ // working-tree states that no longer line up with this conversation, so
5092
+ // restoring one would clobber the rewound tree.
3381
5093
  async retractFromIndex(index) {
3382
5094
  const cut = this.retractionCut(index);
3383
5095
  const removed = this.messages.length - cut;
3384
5096
  if (removed === 0) return 0;
3385
5097
  this.messages = this.messages.slice(0, cut);
3386
5098
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
5099
+ this.opts.registry?.resetVolatileState();
3387
5100
  this.turnMarkers = this.turnMarkers.filter((m) => m.userIndex < cut);
5101
+ this.redoStack = [];
3388
5102
  return removed;
3389
5103
  }
3390
5104
  async appendContextMessage(text, role = "user") {
@@ -3418,7 +5132,7 @@ var AgentLoop = class {
3418
5132
  const input = this.titleInput;
3419
5133
  if (!input) return;
3420
5134
  this.titleScheduled = true;
3421
- scheduleSessionTitle(store, input, this.opts.model);
5135
+ scheduleSessionTitle(store, input, this.getAuxModel());
3422
5136
  }
3423
5137
  // A user interrupt (Esc) ends the turn mid-stream, before the normal
3424
5138
  // assistant-message persistence runs. Whatever text and tool calls already
@@ -3463,6 +5177,13 @@ var AgentLoop = class {
3463
5177
  }
3464
5178
  async *stream(input, signal, opts) {
3465
5179
  this.syncSystemMessage();
5180
+ const git = getGitSummaryCached(this.opts.cwd);
5181
+ this.turnGitContext = git ? formatGitSummary(git) : null;
5182
+ this.resolveAuxModel();
5183
+ if (this.auxModelError) {
5184
+ yield { type: "notice", message: this.auxModelError };
5185
+ this.auxModelError = null;
5186
+ }
3466
5187
  const inputText = typeof input === "string" ? input : input.text;
3467
5188
  const images = typeof input === "string" ? [] : input.images;
3468
5189
  if (this.titleInput === void 0 && inputText.trim().length > 0) {
@@ -3492,9 +5213,12 @@ var AgentLoop = class {
3492
5213
  this.maybeScheduleTitle();
3493
5214
  const { config, registry, cwd } = this.opts;
3494
5215
  const aiTools = this.buildAiTools();
5216
+ this.lastToolSignature = null;
5217
+ this.repeatedToolCalls = 0;
3495
5218
  let autoContinues = 0;
3496
5219
  let lastWasNudge = false;
3497
5220
  let usedToolsThisTurn = false;
5221
+ let streamCutContinues = 0;
3498
5222
  let openTodos = [];
3499
5223
  const maxSteps = config.maxSteps;
3500
5224
  let step = 0;
@@ -3508,6 +5232,7 @@ var AgentLoop = class {
3508
5232
  `Max steps (${maxSteps}) reached with no tool execution since the last checkpoint, stopping.`
3509
5233
  )
3510
5234
  };
5235
+ await this.runStopHooks("max-steps");
3511
5236
  return;
3512
5237
  }
3513
5238
  toolExecutedSinceCheckpoint = false;
@@ -3536,7 +5261,9 @@ ${finished.result}`
3536
5261
  );
3537
5262
  if (compacted.compacted) {
3538
5263
  this.messages = await this.applyCompactionSummary(compacted, signal);
5264
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
3539
5265
  this.turnMarkers = [];
5266
+ this.opts.registry?.resetVolatileState();
3540
5267
  }
3541
5268
  const maxRetries = Math.max(0, config.streamMaxRetries);
3542
5269
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
@@ -3546,7 +5273,9 @@ ${finished.result}`
3546
5273
  let lastFinishReason;
3547
5274
  let lastUsage;
3548
5275
  let sawReasoning = false;
5276
+ let wasTruncated = false;
3549
5277
  let oversizedImagesStripped = false;
5278
+ const hasOutput = () => text.trim().length > 0 || toolCalls.length > 0;
3550
5279
  for (let attempt = 0; ; attempt++) {
3551
5280
  text = "";
3552
5281
  toolCalls.length = 0;
@@ -3554,6 +5283,7 @@ ${finished.result}`
3554
5283
  lastFinishReason = void 0;
3555
5284
  lastUsage = void 0;
3556
5285
  sawReasoning = false;
5286
+ wasTruncated = false;
3557
5287
  const timeoutScale = Math.min(attempt + 1, 3);
3558
5288
  try {
3559
5289
  for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
@@ -3568,8 +5298,9 @@ ${finished.result}`
3568
5298
  yield event;
3569
5299
  } else if (event.type === "finish") {
3570
5300
  lastFinishReason = event.finishReason;
5301
+ if (event.truncated) wasTruncated = true;
3571
5302
  lastUsage = event.usage;
3572
- yield event;
5303
+ yield this.foldSubagentUsage(event);
3573
5304
  } else if (event.type === "error") {
3574
5305
  failure = event.error;
3575
5306
  } else {
@@ -3583,9 +5314,10 @@ ${finished.result}`
3583
5314
  }
3584
5315
  if (signal.aborted) {
3585
5316
  yield* this.persistInterrupted(text, toolCalls);
5317
+ await this.runStopHooks("aborted");
3586
5318
  return;
3587
5319
  }
3588
- if (!failure && (text.length > 0 || toolCalls.length > 0)) break;
5320
+ if (!failure && hasOutput()) break;
3589
5321
  if (failure && !oversizedImagesStripped && isOversizedImageError(failure)) {
3590
5322
  oversizedImagesStripped = true;
3591
5323
  const removed = await this.stripOversizedImages();
@@ -3599,7 +5331,7 @@ ${finished.result}`
3599
5331
  }
3600
5332
  if (failure && !isRetryableStreamError(failure)) break;
3601
5333
  if (attempt >= maxRetries) break;
3602
- if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
5334
+ if (!failure && !hasOutput() && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
3603
5335
  break;
3604
5336
  const delayMs = computeRetryDelayMs(attempt, failure ? baseDelay : baseDelay * 2, failure);
3605
5337
  yield {
@@ -3611,14 +5343,16 @@ ${finished.result}`
3611
5343
  };
3612
5344
  if (!await sleep(delayMs, signal)) {
3613
5345
  yield* this.persistInterrupted(text, toolCalls);
5346
+ await this.runStopHooks("aborted");
3614
5347
  return;
3615
5348
  }
3616
5349
  }
3617
5350
  if (failure) {
3618
5351
  yield { type: "error", error: failure };
5352
+ await this.runStopHooks("error");
3619
5353
  return;
3620
5354
  }
3621
- if (text.length === 0 && toolCalls.length === 0) {
5355
+ if (!hasOutput()) {
3622
5356
  if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
3623
5357
  autoContinues++;
3624
5358
  lastWasNudge = true;
@@ -3638,6 +5372,7 @@ ${finished.result}`
3638
5372
  type: "error",
3639
5373
  error: new Error("The model returned an empty response after retries; ending the turn.")
3640
5374
  };
5375
+ await this.runStopHooks("empty");
3641
5376
  return;
3642
5377
  }
3643
5378
  const assistantMessage = {
@@ -3655,6 +5390,27 @@ ${finished.result}`
3655
5390
  this.messages.push(assistantMessage);
3656
5391
  await this.persist(assistantMessage);
3657
5392
  if (toolCalls.length === 0) {
5393
+ if (wasTruncated) {
5394
+ if (config.permissionMode !== "plan" && streamCutContinues < config.streamMaxRetries) {
5395
+ streamCutContinues++;
5396
+ yield {
5397
+ type: "notice",
5398
+ message: `reply cut short by a stream timeout; asking the model to resume where it stopped (${streamCutContinues}/${config.streamMaxRetries}).`
5399
+ };
5400
+ const nudge = { role: "user", content: TRUNCATED_CONTINUE_NUDGE };
5401
+ this.messages.push(nudge);
5402
+ await this.persist(nudge);
5403
+ continue;
5404
+ }
5405
+ if (config.permissionMode !== "plan") {
5406
+ yield {
5407
+ type: "notice",
5408
+ message: 'reply was cut short by a stream timeout and resume attempts are exhausted, handing the turn back \u2014 reply "continue" to keep going.'
5409
+ };
5410
+ }
5411
+ await this.runStopHooks("completed");
5412
+ return;
5413
+ }
3658
5414
  let reason = null;
3659
5415
  let nudgeText = AUTO_CONTINUE_NUDGE;
3660
5416
  if (openTodos.length > 0) {
@@ -3662,13 +5418,27 @@ ${finished.result}`
3662
5418
  nudgeText = `[auto-continue] You ended your turn with unfinished todo item(s): ${openTodos.map((t) => `"${t}"`).join(
3663
5419
  ", "
3664
5420
  )}. Complete them now with tool calls, or update the list with todo_write if they are no longer needed.`;
5421
+ } else if (usedToolsThisTurn && !signal.aborted) {
5422
+ const complete = await checkTaskComplete(
5423
+ this.getAuxModel(),
5424
+ inputText,
5425
+ text,
5426
+ signal,
5427
+ this.getAuxTemperature()
5428
+ );
5429
+ if (complete === false) {
5430
+ reason = "completion check reports the task unfinished";
5431
+ } else if (complete === null) {
5432
+ if (lastWasNudge) {
5433
+ reason = "reply still had no tool calls";
5434
+ } else if (PENDING_WORK_PATTERN.test(text)) {
5435
+ reason = "reply announced unfinished work";
5436
+ }
5437
+ }
3665
5438
  } else if (lastWasNudge) {
3666
5439
  reason = "reply still had no tool calls";
3667
5440
  } else if (PENDING_WORK_PATTERN.test(text)) {
3668
5441
  reason = "reply announced unfinished work";
3669
- } else if (usedToolsThisTurn && !signal.aborted) {
3670
- const complete = await checkTaskComplete(this.opts.model, inputText, text, signal);
3671
- if (complete === false) reason = "completion check reports the task unfinished";
3672
5442
  }
3673
5443
  if (reason !== null && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
3674
5444
  autoContinues++;
@@ -3691,7 +5461,7 @@ ${finished.result}`
3691
5461
  message: `${reason}; auto-continue limit reached, handing the turn back \u2014 reply "continue" to keep going.`
3692
5462
  };
3693
5463
  }
3694
- await this.runEventHooks("Stop");
5464
+ await this.runStopHooks("completed");
3695
5465
  return;
3696
5466
  }
3697
5467
  lastWasNudge = false;
@@ -3722,6 +5492,24 @@ ${finished.result}`
3722
5492
  this.messages.push(toolMessage);
3723
5493
  await this.persist(toolMessage);
3724
5494
  answered.add(call.id);
5495
+ if (result.images && result.images.length > 0) {
5496
+ const imageMessage = {
5497
+ role: "user",
5498
+ content: [
5499
+ ...result.images.map((img) => ({
5500
+ type: "image",
5501
+ image: img.data,
5502
+ mimeType: img.mimeType
5503
+ })),
5504
+ ...result.images.map((img) => ({
5505
+ type: "text",
5506
+ text: `[image from ${call.name}: ${img.path}]`
5507
+ }))
5508
+ ]
5509
+ };
5510
+ this.messages.push(imageMessage);
5511
+ await this.persist(imageMessage);
5512
+ }
3725
5513
  if (call.name === "todo_write" && !result.isError) {
3726
5514
  openTodos = pendingTodoTitles(call.args);
3727
5515
  }
@@ -3754,21 +5542,21 @@ ${finished.result}`
3754
5542
  yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
3755
5543
  }
3756
5544
  }
3757
- if (signal.aborted) return;
5545
+ if (signal.aborted) {
5546
+ await this.runStopHooks("aborted");
5547
+ return;
5548
+ }
3758
5549
  step++;
3759
5550
  }
3760
5551
  }
3761
5552
  // The permission mode can change at runtime, so the system message is
3762
5553
  // recomputed at the start of every turn instead of being frozen by the
3763
5554
  // constructor. Also restores a system message after loadMessages() (resume,
3764
- // /model switch) replaced the history with one that has none. Git context
3765
- // (branch, dirty count, recent commits) is refreshed here too, so the model
3766
- // always sees the current repo state; any git failure is silently skipped.
3767
- // Project memory (AGENTS.md in the cwd) and user memory (~/.star-cli/
3768
- // MEMORY.md) are appended as delimited blocks; both cache by mtime, so this
3769
- // stays cheap per turn. The
3770
- // skills listing is refreshed here as well, so skills added mid-session
3771
- // show up in the next turn's system prompt.
5555
+ // /model switch) replaced the history with one that has none. Everything in
5556
+ // here is stable across turns (base prompt, plan-mode flag, memories, skills
5557
+ // — the latter two cached by mtime), which keeps the prompt-cache prefix
5558
+ // intact; volatile git state rides per-turn on the latest user message
5559
+ // instead (attachTurnContext).
3772
5560
  syncSystemMessage() {
3773
5561
  const base = this.opts.system;
3774
5562
  const plan = this.opts.config.permissionMode === "plan";
@@ -3781,26 +5569,29 @@ ${finished.result}`
3781
5569
  if (userMemory) parts.push(userMemory);
3782
5570
  const skills = discoverSkills(this.opts.cwd);
3783
5571
  if (skills.length > 0) parts.push(formatSkillsBlock(skills));
3784
- const git = getGitSummaryCached(this.opts.cwd);
3785
- if (git) parts.push(formatGitSummary(git));
3786
5572
  if (parts.length === 0) return;
3787
5573
  const content = parts.join("\n\n");
3788
5574
  const head = this.messages[0];
3789
5575
  if (head?.role === "system" && typeof head.content === "string") {
3790
- if (head.content !== content) head.content = content;
5576
+ if (head.content !== content) this.messages[0] = { role: "system", content };
3791
5577
  } else if (head?.role !== "system") {
3792
5578
  this.messages.unshift({ role: "system", content });
3793
5579
  }
3794
5580
  }
3795
5581
  async applyCompactionSummary(compacted, signal) {
3796
- const { config, model } = this.opts;
3797
- if (config.contextCompaction !== "summary" || !model) {
5582
+ const { config } = this.opts;
5583
+ if (config.contextCompaction !== "summary") {
3798
5584
  return compacted.messages;
3799
5585
  }
3800
5586
  const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
3801
5587
  const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
3802
5588
  try {
3803
- const summary = await summarizeMessages(dropped, model, signal);
5589
+ const summary = await summarizeMessages(
5590
+ dropped,
5591
+ this.getAuxModel(),
5592
+ signal,
5593
+ this.getAuxTemperature()
5594
+ );
3804
5595
  const messages = compacted.messages.slice();
3805
5596
  messages[headCount] = {
3806
5597
  role: "user",
@@ -3813,15 +5604,15 @@ ${summary}`
3813
5604
  }
3814
5605
  }
3815
5606
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
3816
- const { streamChat } = await import("./stream-EFQPULQQ.js");
3817
5607
  for await (const event of streamChat({
3818
5608
  model: this.opts.model,
3819
- messages: this.messages,
5609
+ messages: attachTurnContext(this.messages, this.turnGitContext),
3820
5610
  tools: aiTools,
3821
5611
  abortSignal: signal,
3822
5612
  providerMetadata: this.opts.providerMetadata,
3823
- idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
3824
- firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
5613
+ idleTimeoutMs: (this.opts.streamIdleTimeoutSec ?? this.opts.config.streamIdleTimeoutSec) * 1e3 * timeoutScale,
5614
+ firstPartTimeoutMs: (this.opts.streamFirstChunkTimeoutSec ?? this.opts.config.streamFirstChunkTimeoutSec) * 1e3 * timeoutScale,
5615
+ temperature: this.opts.temperature
3825
5616
  })) {
3826
5617
  if (event.type === "finish" && event.truncated) {
3827
5618
  yield {
@@ -3844,6 +5635,14 @@ ${summary}`
3844
5635
  }
3845
5636
  return tools;
3846
5637
  }
5638
+ // Fires the Stop hooks for a turn's end, on every termination path (normal
5639
+ // completion, Esc abort, exhausted stream retries, terminal empty replies,
5640
+ // the maxSteps no-progress stop). The reason rides in STAR_TOOL_INPUT as
5641
+ // {"reason": ...}: Stop hooks ignore tool matchers and the variable was
5642
+ // never set for Stop before, so existing hooks are unaffected.
5643
+ async runStopHooks(reason) {
5644
+ await this.runEventHooks("Stop", void 0, { reason });
5645
+ }
3847
5646
  async runEventHooks(event, toolName, toolInput) {
3848
5647
  const empty = { blocked: false, warnings: [] };
3849
5648
  if (this.opts.config.hooks.length === 0) return empty;
@@ -3860,6 +5659,47 @@ ${summary}`
3860
5659
  return empty;
3861
5660
  }
3862
5661
  }
5662
+ // Accumulates a settled subagent's usage. When the child provably ran on a
5663
+ // different, fully priced model, the counts are first converted into
5664
+ // this-model-priced equivalents (convertUsagePricing): session cost is
5665
+ // token totals × the active model's price, so the conversion keeps the $
5666
+ // estimate accurate where raw sums would misprice.
5667
+ addSubagentUsage(usage, childModel) {
5668
+ const converted = convertUsagePricing(usage, childModel, this.opts.model, this.opts.config);
5669
+ const pending = this.subagentUsage ?? {
5670
+ promptTokens: 0,
5671
+ completionTokens: 0,
5672
+ totalTokens: 0
5673
+ };
5674
+ pending.promptTokens += converted.promptTokens;
5675
+ pending.completionTokens += converted.completionTokens;
5676
+ pending.totalTokens += converted.totalTokens;
5677
+ if (converted.cachedPromptTokens) {
5678
+ pending.cachedPromptTokens = (pending.cachedPromptTokens ?? 0) + converted.cachedPromptTokens;
5679
+ }
5680
+ if (converted.cacheReadInputTokens) {
5681
+ pending.cacheReadInputTokens = (pending.cacheReadInputTokens ?? 0) + converted.cacheReadInputTokens;
5682
+ }
5683
+ this.subagentUsage = pending;
5684
+ }
5685
+ // Folds pending subagent usage into an outgoing finish event (and clears
5686
+ // it). A finish without usage reports nothing to consumers, so the pending
5687
+ // amount is kept for the next finish that carries usage instead.
5688
+ foldSubagentUsage(event) {
5689
+ const pending = this.subagentUsage;
5690
+ if (!pending || !event.usage) return event;
5691
+ this.subagentUsage = null;
5692
+ const usage = {
5693
+ promptTokens: event.usage.promptTokens + pending.promptTokens,
5694
+ completionTokens: event.usage.completionTokens + pending.completionTokens,
5695
+ totalTokens: event.usage.totalTokens + pending.totalTokens
5696
+ };
5697
+ const cachedPromptTokens = (event.usage.cachedPromptTokens ?? 0) + (pending.cachedPromptTokens ?? 0);
5698
+ const cacheReadInputTokens = (event.usage.cacheReadInputTokens ?? 0) + (pending.cacheReadInputTokens ?? 0);
5699
+ if (cachedPromptTokens > 0) usage.cachedPromptTokens = cachedPromptTokens;
5700
+ if (cacheReadInputTokens > 0) usage.cacheReadInputTokens = cacheReadInputTokens;
5701
+ return { ...event, usage };
5702
+ }
3863
5703
  // opencode's doom_loop guard: returns a refusal once the identical tool
3864
5704
  // call (name + stable-serialized args) has repeated config.doomLoopThreshold
3865
5705
  // times in a row; 0 disables. Any different signature resets the streak.
@@ -3956,17 +5796,28 @@ export {
3956
5796
  compactMessages,
3957
5797
  WORKING_DIFF_MAX_LINES,
3958
5798
  isGitRepo,
3959
- getGitSummary,
5799
+ getGitSummaryCached,
3960
5800
  collectCommitContext,
3961
5801
  buildCommitPrompt,
3962
5802
  collectWorkingDiff,
3963
5803
  MAX_IMAGE_DIMENSION,
3964
- probeImageDimensions,
3965
5804
  reconcileToolCalls,
5805
+ listModels,
5806
+ resolveModelConfig,
5807
+ createModel,
5808
+ reasoningEffortMetadata,
5809
+ starHome,
5810
+ globalConfigPath,
5811
+ sessionsDir,
5812
+ userMemoryPath,
5813
+ envFilePath,
5814
+ projectConfigPath,
3966
5815
  isSensitivePath,
3967
5816
  isAllowedByRules,
3968
5817
  buildAllowRule,
3969
5818
  isDangerousCommand,
5819
+ removeTreeRepo,
5820
+ pruneTreeReposExcept,
3970
5821
  diffTreeNames,
3971
5822
  listSnapshots,
3972
5823
  clearSnapshots,
@@ -3979,6 +5830,7 @@ export {
3979
5830
  bashTool,
3980
5831
  SKIP_DIRS,
3981
5832
  createIgnorePredicate,
5833
+ downsampleImageIfNeeded,
3982
5834
  formatTaskList,
3983
5835
  formatTaskStarted,
3984
5836
  formatTaskFinished,
@@ -3991,6 +5843,7 @@ export {
3991
5843
  createDefaultRegistry,
3992
5844
  appendUserMemory,
3993
5845
  PLAN_MODE_PROMPT,
5846
+ attachTurnContext,
3994
5847
  AgentLoop
3995
5848
  };
3996
- //# sourceMappingURL=chunk-I6FDXAWX.js.map
5849
+ //# sourceMappingURL=chunk-OHLA2DP7.js.map