@cryer/star-cli 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  AgentLoop,
4
+ ConfigSchema,
4
5
  MAX_IMAGE_DIMENSION,
5
6
  SKIP_DIRS,
6
7
  TodoStore,
@@ -13,12 +14,15 @@ import {
13
14
  collectCommitContext,
14
15
  collectWorkingDiff,
15
16
  compactMessages,
17
+ contextWindowTokens,
16
18
  createDefaultRegistry,
17
19
  createIgnorePredicate,
18
20
  defaultAgentTasks,
19
21
  defaultTaskManager,
22
+ diffTreeNames,
20
23
  discoverSkills,
21
24
  estimateTokens,
25
+ formatAgentTaskUpdate,
22
26
  formatTaskFinished,
23
27
  formatTaskList,
24
28
  formatTaskStarted,
@@ -39,9 +43,8 @@ import {
39
43
  resolveShell,
40
44
  resolveSnapshotContent,
41
45
  rewindToSnapshot,
42
- summarizeMessages,
43
- undoTurnSnapshots
44
- } from "./chunk-BV4PLM6X.js";
46
+ summarizeMessages
47
+ } from "./chunk-K3R42LOX.js";
45
48
  import {
46
49
  envFilePath,
47
50
  globalConfigPath,
@@ -49,11 +52,20 @@ import {
49
52
  sessionsDir,
50
53
  starHome,
51
54
  userMemoryPath
52
- } from "./chunk-LADQCYKL.js";
55
+ } from "./chunk-B6P4VUBE.js";
53
56
 
54
57
  // src/main.tsx
55
58
  import { Command } from "commander";
56
59
 
60
+ // src/cli/terminal-text.ts
61
+ var UNSAFE_CHARS = /[\x00-\x09\x0b-\x1f\x7f]/;
62
+ var CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
63
+ var TABS = /\t/g;
64
+ function toTerminalSafe(text) {
65
+ if (!UNSAFE_CHARS.test(text)) return text;
66
+ return text.replace(TABS, " ").replace(CONTROL_CHARS, "");
67
+ }
68
+
57
69
  // src/cli/format.ts
58
70
  function summarizeArgs(args, maxLength = 120) {
59
71
  let json;
@@ -98,7 +110,7 @@ function buildDisplayMessages(messages) {
98
110
  if (message.role === "user" || message.role === "assistant") {
99
111
  const text = coreMessageText(message);
100
112
  if (text) {
101
- display.push({ id: display.length, role: message.role, text });
113
+ display.push({ id: display.length, role: message.role, text: toTerminalSafe(text) });
102
114
  } else {
103
115
  collapsed++;
104
116
  }
@@ -110,7 +122,7 @@ function buildDisplayMessages(messages) {
110
122
  display.push({
111
123
  id: display.length,
112
124
  role: "system",
113
- text: `\u5DF2\u6062\u590D ${collapsed} \u6761\u5386\u53F2\u6D88\u606F`
125
+ text: `Restored ${collapsed} history message(s) not shown here.`
114
126
  });
115
127
  }
116
128
  return display;
@@ -168,7 +180,15 @@ function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools =
168
180
  if (tools.hasConvert) {
169
181
  return {
170
182
  command: "convert",
171
- args: [inputFile, "-resize", `${maxDimension}x${maxDimension}>`, outputFile]
183
+ // -auto-orient keeps EXIF-rotated photos upright after the resize
184
+ // strips their orientation metadata.
185
+ args: [
186
+ inputFile,
187
+ "-auto-orient",
188
+ "-resize",
189
+ `${maxDimension}x${maxDimension}>`,
190
+ outputFile
191
+ ]
172
192
  };
173
193
  }
174
194
  if (tools.hasFfmpeg) {
@@ -236,6 +256,7 @@ var MAX_MENTION_BYTES = 100 * 1024;
236
256
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
237
257
  var MAX_DIR_MENTION_ENTRIES = 200;
238
258
  var MAX_DIR_MENTION_DEPTH = 10;
259
+ var MAX_DIR_MENTION_TOTAL = 5e3;
239
260
  var IMAGE_MIME_TYPES = {
240
261
  ".png": "image/png",
241
262
  ".jpg": "image/jpeg",
@@ -261,7 +282,7 @@ async function readImageInput(filePath, cwd) {
261
282
  });
262
283
  return result.image;
263
284
  }
264
- var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\]*|[A-Za-z0-9._\-/\\]+)/g;
285
+ var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]*|[A-Za-z0-9._\-/\\\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]+)/g;
265
286
  function parseMentions(text) {
266
287
  const mentions = [];
267
288
  const seen = /* @__PURE__ */ new Set();
@@ -294,13 +315,18 @@ function isBinaryContent(buf) {
294
315
  return probe.includes(0);
295
316
  }
296
317
  async function listDirEntries(dir, prefix, depth, state, ignore) {
318
+ if (state.capped) return;
297
319
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
298
320
  if (!entries) return;
299
- const visible = entries.filter((entry) => entry.isDirectory() || entry.isFile()).filter((entry) => !isSensitivePath(entry.name)).filter((entry) => !ignore?.(path2.join(dir, entry.name), entry.isDirectory())).sort((a, b) => {
321
+ const visible = entries.filter((entry) => entry.isDirectory() || entry.isFile()).filter((entry) => !(entry.isDirectory() && SKIP_DIRS.has(entry.name))).filter((entry) => !isSensitivePath(entry.name)).filter((entry) => !ignore?.(path2.join(dir, entry.name), entry.isDirectory())).sort((a, b) => {
300
322
  const dirDiff = Number(b.isDirectory()) - Number(a.isDirectory());
301
323
  return dirDiff !== 0 ? dirDiff : a.name.localeCompare(b.name);
302
324
  });
303
325
  for (let i = 0; i < visible.length; i++) {
326
+ if (state.total >= MAX_DIR_MENTION_TOTAL) {
327
+ state.capped = true;
328
+ return;
329
+ }
304
330
  const entry = visible[i];
305
331
  if (!entry) continue;
306
332
  state.total += 1;
@@ -325,11 +351,13 @@ async function listDirEntries(dir, prefix, depth, state, ignore) {
325
351
  }
326
352
  async function directoryBlock(abs, mention, ignore) {
327
353
  const display = `${mention.replace(/\\/g, "/").replace(/\/+$/, "")}/`;
328
- const state = { lines: [`${display}`], shown: 0, total: 0 };
354
+ const state = { lines: [`${display}`], shown: 0, total: 0, capped: false };
329
355
  const probe = await readdir(abs).catch(() => null);
330
356
  if (!probe) return null;
331
357
  await listDirEntries(abs, "", 1, state, ignore);
332
- if (state.total > state.shown) {
358
+ if (state.capped) {
359
+ state.lines.push("... (truncated, many more entries)");
360
+ } else if (state.total > state.shown) {
333
361
  state.lines.push(
334
362
  `... (truncated, ${state.total - state.shown} more entries \u2014 use glob/grep tools or mention a subdirectory to see more)`
335
363
  );
@@ -473,11 +501,56 @@ import { parse, stringify } from "smol-toml";
473
501
  async function addAllowRule(rule) {
474
502
  return addPermissionRule("allow", rule);
475
503
  }
504
+ function tomlString(value) {
505
+ return JSON.stringify(value);
506
+ }
507
+ async function writeFileAtomic(filePath, content) {
508
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
509
+ await fs.promises.writeFile(tmpPath, content, "utf8");
510
+ await fs.promises.rename(tmpPath, filePath);
511
+ }
512
+ function upsertPermissionRuleText(content, kind, rules, hadPermissionsTable) {
513
+ const newLine = `${kind} = [${rules.map(tomlString).join(", ")}]`;
514
+ const lines = content.split("\n");
515
+ let sectionStart = -1;
516
+ let sectionEnd = lines.length;
517
+ for (let i = 0; i < lines.length; i++) {
518
+ const line = lines[i] ?? "";
519
+ if (!/^\s*\[/.test(line)) continue;
520
+ if (sectionStart === -1) {
521
+ if (/^\s*\[\s*permissions\s*\]\s*(?:#.*)?$/.test(line)) sectionStart = i;
522
+ } else {
523
+ sectionEnd = i;
524
+ break;
525
+ }
526
+ }
527
+ if (sectionStart === -1) {
528
+ if (hadPermissionsTable) return null;
529
+ const separator = content === "" ? "" : content.endsWith("\n") ? "\n" : "\n\n";
530
+ return `${content}${separator}[permissions]
531
+ ${newLine}
532
+ `;
533
+ }
534
+ const inlineArray = new RegExp(`^(\\s*)${kind}\\s*=\\s*\\[.*\\](\\s*(?:#.*)?)$`);
535
+ const anyKey = new RegExp(`^\\s*${kind}\\s*=`);
536
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
537
+ const line = lines[i] ?? "";
538
+ const match = inlineArray.exec(line);
539
+ if (match) {
540
+ lines[i] = `${match[1]}${newLine}${match[2]}`;
541
+ return lines.join("\n");
542
+ }
543
+ if (anyKey.test(line)) return null;
544
+ }
545
+ lines.splice(sectionStart + 1, 0, newLine);
546
+ return lines.join("\n");
547
+ }
476
548
  async function addPermissionRule(kind, rule) {
477
549
  const filePath = globalConfigPath();
550
+ let content = "";
478
551
  let raw = {};
479
552
  try {
480
- const content = await fs.promises.readFile(filePath, "utf8");
553
+ content = await fs.promises.readFile(filePath, "utf8");
481
554
  raw = parse(content);
482
555
  } catch (error) {
483
556
  if (error.code !== "ENOENT") throw error;
@@ -485,117 +558,47 @@ async function addPermissionRule(kind, rule) {
485
558
  const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
486
559
  const rules = Array.isArray(permissions[kind]) ? permissions[kind] : [];
487
560
  if (rules.includes(rule)) return false;
488
- permissions[kind] = [...rules, rule];
489
- raw.permissions = permissions;
561
+ const nextRules = [...rules, rule];
490
562
  await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
491
- await fs.promises.writeFile(filePath, stringify(raw), "utf8");
563
+ const updated = upsertPermissionRuleText(content, kind, nextRules, raw.permissions !== void 0);
564
+ if (updated !== null) {
565
+ await writeFileAtomic(filePath, updated);
566
+ return true;
567
+ }
568
+ permissions[kind] = nextRules;
569
+ raw.permissions = permissions;
570
+ await writeFileAtomic(filePath, stringify(raw));
492
571
  return true;
493
572
  }
573
+ function upsertTopLevelKeyText(content, key, value) {
574
+ const newLine = `${key} = ${tomlString(value)}`;
575
+ if (content.trim() === "") return `${newLine}
576
+ `;
577
+ const lines = content.split("\n");
578
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
579
+ const end = firstTable === -1 ? lines.length : firstTable;
580
+ const keyLine = new RegExp(`^\\s*${key}\\s*=`);
581
+ for (let i = 0; i < end; i++) {
582
+ const line = lines[i] ?? "";
583
+ if (keyLine.test(line)) {
584
+ const comment = /\s+#.*$/.exec(line);
585
+ lines[i] = `${newLine}${comment?.[0] ?? ""}`;
586
+ return lines.join("\n");
587
+ }
588
+ }
589
+ lines.splice(end, 0, newLine);
590
+ return lines.join("\n");
591
+ }
494
592
  async function savePermissionMode(mode) {
495
593
  const filePath = globalConfigPath();
496
- let raw = {};
594
+ let content = "";
497
595
  try {
498
- const content = await fs.promises.readFile(filePath, "utf8");
499
- raw = parse(content);
596
+ content = await fs.promises.readFile(filePath, "utf8");
500
597
  } catch (error) {
501
598
  if (error.code !== "ENOENT") throw error;
502
599
  }
503
- raw.permissionMode = mode;
504
600
  await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
505
- await fs.promises.writeFile(filePath, stringify(raw), "utf8");
506
- }
507
-
508
- // src/config/schema.ts
509
- import { z } from "zod";
510
- var ProviderConfigSchema = z.object({
511
- name: z.string(),
512
- // openai-responses talks to the Responses API (/responses) instead of chat
513
- // completions — the wire format codex uses, and the native API of
514
- // reasoning-model relays that otherwise hide reasoning behind empty chat
515
- // completions. streamChat sends strictSchemas:false + store:false with it.
516
- protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
517
- baseURL: z.string(),
518
- apiKeyEnv: z.string().optional(),
519
- apiKey: z.string().optional(),
520
- headers: z.record(z.string()).optional()
521
- });
522
- var ModelConfigSchema = z.object({
523
- name: z.string(),
524
- provider: z.string(),
525
- model: z.string(),
526
- maxTokens: z.number().int().positive().optional(),
527
- // Per-model context window; overrides the top-level contextMaxTokens for
528
- // compaction and the status bar when set.
529
- contextMaxTokens: z.number().int().positive().optional(),
530
- // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
531
- // the model is treated as unpriced.
532
- promptPrice: z.number().nonnegative().optional(),
533
- completionPrice: z.number().nonnegative().optional()
534
- });
535
- var PermissionsConfigSchema = z.object({
536
- allow: z.array(z.string()).default([]),
537
- deny: z.array(z.string()).default([])
538
- });
539
- var HookConfigSchema = z.object({
540
- event: z.enum(["PreToolUse", "PostToolUse", "Stop"]),
541
- matcher: z.string().refine(
542
- (pattern) => {
543
- try {
544
- new RegExp(pattern);
545
- return true;
546
- } catch {
547
- return false;
548
- }
549
- },
550
- { message: "invalid regular expression" }
551
- ).optional(),
552
- command: z.string().min(1),
553
- timeoutSec: z.number().positive().default(30)
554
- });
555
- var ConfigSchema = z.object({
556
- defaultModel: z.string().default(""),
557
- permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
558
- providers: z.array(ProviderConfigSchema).default([]),
559
- models: z.array(ModelConfigSchema).default([]),
560
- maxSteps: z.number().int().positive().default(50),
561
- contextMaxTokens: z.number().int().positive().default(1e5),
562
- // Seconds without any stream part before a stalled response is ended
563
- // gracefully (some relays never send the terminal chunks). Applies once
564
- // streaming has started; the first part gets a longer, fixed allowance.
565
- streamIdleTimeoutSec: z.number().positive().default(20),
566
- // Seconds to wait for the very first stream part before giving up; slow
567
- // thinking-model relays can buffer for minutes before sending anything.
568
- streamFirstChunkTimeoutSec: z.number().positive().default(300),
569
- // Extra attempts per model request when a stream fails transiently
570
- // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
571
- // relay hiccup does not silently end a half-finished turn. 0 disables.
572
- // The wait between attempts honors Retry-After headers and otherwise backs
573
- // off exponentially with jitter (llm/retry.ts); retries after a
574
- // timeout/empty failure also scale both stream timeouts up (attempt number
575
- // ×, capped at 3x) since the relay is likely overloaded.
576
- streamMaxRetries: z.number().int().min(0).default(3),
577
- // How many consecutive unproductive replies may be nudged before the turn
578
- // is handed back: a text-only reply that announces pending work ("我将…",
579
- // "I will…") or answers a nudge with more words, or an empty reply that
580
- // survived the stream retry budget (steered with a nudge — a changed
581
- // request — since identical resends already failed). Tool calls reset the
582
- // count, so a productive task keeps going; exhaustion surfaces a notice
583
- // instead of stopping silently. Never fires in plan mode. 0 disables.
584
- maxAutoContinues: z.number().int().min(0).default(2),
585
- contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
586
- // Optional per-session dollar budget; when set, the REPL stops the session
587
- // once the estimated cost for the session reaches this amount.
588
- sessionBudgetUsd: z.number().positive().optional(),
589
- // Terminal bell (REPL only): ring when a turn takes longer than
590
- // notifyBellThresholdSec, and when a background task finishes.
591
- // STAR_NO_NOTIFY=1 disables without touching the config.
592
- notifyBell: z.boolean().default(true),
593
- notifyBellThresholdSec: z.number().positive().default(10),
594
- permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),
595
- hooks: z.array(HookConfigSchema).default([])
596
- });
597
- function contextWindowTokens(config, modelName) {
598
- return config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens;
601
+ await writeFileAtomic(filePath, upsertTopLevelKeyText(content, "permissionMode", mode));
599
602
  }
600
603
 
601
604
  // src/llm/provider.ts
@@ -674,10 +677,13 @@ function createSseNormalizingFetch(baseFetch = fetch) {
674
677
  return response;
675
678
  }
676
679
  const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new SseNormalizeTransform()).pipeThrough(new TextEncoderStream());
680
+ const headers = new Headers(response.headers);
681
+ headers.delete("content-length");
682
+ headers.delete("content-encoding");
677
683
  return new Response(body, {
678
684
  status: response.status,
679
685
  statusText: response.statusText,
680
- headers: response.headers
686
+ headers
681
687
  });
682
688
  };
683
689
  }
@@ -715,7 +721,7 @@ function createModel(config, modelName) {
715
721
  baseURL: provider.baseURL,
716
722
  apiKey,
717
723
  headers: provider.headers
718
- })(modelConfig.model);
724
+ })(modelConfig.model, {}, { includeUsage: true });
719
725
  case "openai-responses":
720
726
  return createOpenAI({
721
727
  baseURL: provider.baseURL,
@@ -738,6 +744,21 @@ function checkpointsDir(sessionDir) {
738
744
  function indexPath(sessionDir) {
739
745
  return path4.join(checkpointsDir(sessionDir), "index.json");
740
746
  }
747
+ var indexWriteQueues = /* @__PURE__ */ new Map();
748
+ function enqueueIndexWrite(sessionDir, op) {
749
+ const queued = indexWriteQueues.get(sessionDir) ?? Promise.resolve();
750
+ const run = queued.then(op);
751
+ indexWriteQueues.set(
752
+ sessionDir,
753
+ run.then(
754
+ () => {
755
+ },
756
+ () => {
757
+ }
758
+ )
759
+ );
760
+ return run;
761
+ }
741
762
  function checkpointContentPath(sessionDir, id) {
742
763
  return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
743
764
  }
@@ -756,32 +777,36 @@ async function listCheckpointRecords(sessionDir) {
756
777
  }
757
778
  }
758
779
  async function appendCheckpointRecord(sessionDir, record, content) {
759
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
760
- if (record.existed && content !== null) {
761
- await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
762
- }
763
- const records = await listCheckpointRecords(sessionDir);
764
- records.push(record);
765
- await fs2.writeFile(indexPath(sessionDir), JSON.stringify(records, null, 2));
780
+ await enqueueIndexWrite(sessionDir, async () => {
781
+ await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
782
+ if (record.existed && content !== null) {
783
+ await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
784
+ }
785
+ const records = await listCheckpointRecords(sessionDir);
786
+ records.push(record);
787
+ await fs2.writeFile(indexPath(sessionDir), JSON.stringify(records, null, 2));
788
+ });
766
789
  }
767
790
  async function removeCheckpointRecords(sessionDir, ids) {
768
791
  if (ids.length === 0) return;
769
- const drop = new Set(ids);
770
- const records = await listCheckpointRecords(sessionDir);
771
- const kept = records.filter((record) => !drop.has(record.id));
772
- if (kept.length === records.length) return;
773
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
774
- await fs2.writeFile(indexPath(sessionDir), JSON.stringify(kept, null, 2));
775
- for (const id of ids) {
776
- await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
777
- }
792
+ await enqueueIndexWrite(sessionDir, async () => {
793
+ const drop = new Set(ids);
794
+ const records = await listCheckpointRecords(sessionDir);
795
+ const kept = records.filter((record) => !drop.has(record.id));
796
+ if (kept.length === records.length) return;
797
+ await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
798
+ await fs2.writeFile(indexPath(sessionDir), JSON.stringify(kept, null, 2));
799
+ for (const id of ids) {
800
+ await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
801
+ }
802
+ });
778
803
  }
779
804
  async function loadSessionSnapshots(sessionDir) {
780
805
  const records = await listCheckpointRecords(sessionDir);
781
806
  return records.map((record) => ({
782
807
  ...record,
783
808
  content: null,
784
- contentFile: record.existed ? checkpointContentPath(sessionDir, record.id) : void 0
809
+ contentFile: record.existed && !record.contentTooLarge ? checkpointContentPath(sessionDir, record.id) : void 0
785
810
  }));
786
811
  }
787
812
 
@@ -802,6 +827,10 @@ function debugWarn(message) {
802
827
  if (process.env.STAR_DEBUG === "1") process.stderr.write(`[star-cli] ${message}
803
828
  `);
804
829
  }
830
+ var FALLBACK_META = /* @__PURE__ */ Symbol("star.fallbackMeta");
831
+ function isFallbackMeta(meta) {
832
+ return meta[FALLBACK_META] === true;
833
+ }
805
834
  function dayKey(date) {
806
835
  const pad = (n) => String(n).padStart(2, "0");
807
836
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
@@ -811,6 +840,9 @@ var SessionStore = class _SessionStore {
811
840
  dir;
812
841
  pendingMeta = null;
813
842
  initialized = false;
843
+ cachedMeta = null;
844
+ metaTmpSeq = 0;
845
+ metaWriteQueue = Promise.resolve();
814
846
  constructor(id) {
815
847
  this.id = id;
816
848
  this.dir = path5.join(sessionsDir(), id);
@@ -840,6 +872,7 @@ var SessionStore = class _SessionStore {
840
872
  } catch {
841
873
  debugWarn(`session ${id}: corrupt meta.json, using defaults`);
842
874
  }
875
+ await store.sweepTmpFiles();
843
876
  store.initialized = true;
844
877
  return store;
845
878
  }
@@ -850,16 +883,40 @@ var SessionStore = class _SessionStore {
850
883
  } catch {
851
884
  return [];
852
885
  }
853
- const metas = [];
854
- for (const entry of entries) {
886
+ const metas = await Promise.all(
887
+ entries.map(async (entry) => {
888
+ try {
889
+ const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
890
+ const meta = JSON.parse(raw);
891
+ if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) return meta;
892
+ } catch {
893
+ }
894
+ return null;
895
+ })
896
+ );
897
+ return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt - a.updatedAt);
898
+ }
899
+ // Meta writes are serialized per instance and land atomically (tmp file +
900
+ // rename) over a cached in-memory meta: the fire-and-forget title/usage
901
+ // writers race the loop's own appends, and a truncated read once turned
902
+ // fallbackMeta's empty defaults into a persisted wipe of model/cwd/usage.
903
+ // A branded fallback meta is never written back for the same reason.
904
+ writeMeta(meta) {
905
+ if (isFallbackMeta(meta)) return Promise.resolve();
906
+ const tmp = `${this.metaPath()}.tmp-${process.pid}-${this.metaTmpSeq++}`;
907
+ const run = this.metaWriteQueue.then(async () => {
855
908
  try {
856
- const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
857
- const meta = JSON.parse(raw);
858
- if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
859
- } catch {
909
+ await fs3.writeFile(tmp, JSON.stringify(meta, null, 2));
910
+ await fs3.rename(tmp, this.metaPath());
911
+ } catch (error) {
912
+ await fs3.unlink(tmp).catch(() => {
913
+ });
914
+ throw error;
860
915
  }
861
- }
862
- return metas.sort((a, b) => b.updatedAt - a.updatedAt);
916
+ });
917
+ this.metaWriteQueue = run.catch(() => {
918
+ });
919
+ return run;
863
920
  }
864
921
  async ensureInitialized() {
865
922
  if (this.initialized) return;
@@ -868,7 +925,8 @@ var SessionStore = class _SessionStore {
868
925
  this.initialized = true;
869
926
  return;
870
927
  }
871
- await fs3.mkdir(this.dir, { recursive: true });
928
+ await fs3.mkdir(this.dir, { recursive: true, mode: 448 });
929
+ await this.sweepTmpFiles();
872
930
  const meta = {
873
931
  id: this.id,
874
932
  title: "",
@@ -877,16 +935,31 @@ var SessionStore = class _SessionStore {
877
935
  createdAt: pending.createdAt,
878
936
  updatedAt: pending.createdAt
879
937
  };
880
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
938
+ await this.writeMeta(meta);
939
+ this.cachedMeta = meta;
881
940
  this.initialized = true;
882
941
  }
942
+ // Removes meta.json.tmp-* orphans left behind by a crashed meta write
943
+ // (tmp file + rename); best-effort, the directory may not even exist yet.
944
+ async sweepTmpFiles() {
945
+ let entries;
946
+ try {
947
+ entries = await fs3.readdir(this.dir);
948
+ } catch {
949
+ return;
950
+ }
951
+ await Promise.all(
952
+ entries.filter((entry) => entry.startsWith("meta.json.tmp-")).map((entry) => fs3.unlink(path5.join(this.dir, entry)).catch(() => {
953
+ }))
954
+ );
955
+ }
883
956
  async append(message) {
884
957
  await this.ensureInitialized();
885
958
  await fs3.appendFile(this.messagesPath(), `${JSON.stringify(message)}
886
959
  `);
887
960
  const meta = await this.meta();
888
961
  meta.updatedAt = Date.now();
889
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
962
+ await this.writeMeta(meta);
890
963
  }
891
964
  async replaceMessages(messages) {
892
965
  await this.ensureInitialized();
@@ -895,7 +968,7 @@ var SessionStore = class _SessionStore {
895
968
  ` : "");
896
969
  const meta = await this.meta();
897
970
  meta.updatedAt = Date.now();
898
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
971
+ await this.writeMeta(meta);
899
972
  }
900
973
  async messages() {
901
974
  let raw;
@@ -918,13 +991,24 @@ var SessionStore = class _SessionStore {
918
991
  }
919
992
  fallbackMeta() {
920
993
  const now = Date.now();
921
- return { id: this.id, title: "", model: "", cwd: "", createdAt: now, updatedAt: now };
994
+ const meta = {
995
+ id: this.id,
996
+ title: "",
997
+ model: "",
998
+ cwd: "",
999
+ createdAt: now,
1000
+ updatedAt: now
1001
+ };
1002
+ Object.defineProperty(meta, FALLBACK_META, { value: true, enumerable: false });
1003
+ return meta;
922
1004
  }
923
1005
  async meta() {
924
1006
  await this.ensureInitialized();
1007
+ if (this.cachedMeta) return this.cachedMeta;
925
1008
  try {
926
1009
  const raw = await fs3.readFile(this.metaPath(), "utf8");
927
- return JSON.parse(raw);
1010
+ this.cachedMeta = JSON.parse(raw);
1011
+ return this.cachedMeta;
928
1012
  } catch {
929
1013
  debugWarn(`session ${this.id}: unreadable meta.json, using defaults`);
930
1014
  return this.fallbackMeta();
@@ -935,7 +1019,7 @@ var SessionStore = class _SessionStore {
935
1019
  const meta = await this.meta();
936
1020
  meta.title = title;
937
1021
  meta.updatedAt = Date.now();
938
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1022
+ await this.writeMeta(meta);
939
1023
  }
940
1024
  async appendCheckpoint(record, content) {
941
1025
  await this.ensureInitialized();
@@ -960,6 +1044,12 @@ var SessionStore = class _SessionStore {
960
1044
  usage.promptTokens += delta.promptTokens;
961
1045
  usage.completionTokens += delta.completionTokens;
962
1046
  usage.totalTokens += delta.totalTokens;
1047
+ if (delta.cachedPromptTokens) {
1048
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + delta.cachedPromptTokens;
1049
+ }
1050
+ if (delta.cacheReadInputTokens) {
1051
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + delta.cacheReadInputTokens;
1052
+ }
963
1053
  meta.usage = usage;
964
1054
  const day = dayKey(/* @__PURE__ */ new Date());
965
1055
  const byDay = meta.usageByDay ?? {};
@@ -969,7 +1059,7 @@ var SessionStore = class _SessionStore {
969
1059
  bucket.totalTokens += delta.totalTokens;
970
1060
  byDay[day] = bucket;
971
1061
  meta.usageByDay = byDay;
972
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1062
+ await this.writeMeta(meta);
973
1063
  }
974
1064
  };
975
1065
 
@@ -1026,6 +1116,33 @@ async function findLatestSession(cwd) {
1026
1116
  const [latest] = await SessionStore.list(cwd);
1027
1117
  return latest ?? null;
1028
1118
  }
1119
+ async function sessionPreview(id, maxBytes = 4096) {
1120
+ let handle = null;
1121
+ try {
1122
+ handle = await fs5.open(path7.join(sessionsDir(), id, "messages.jsonl"), "r");
1123
+ const buffer = Buffer.alloc(maxBytes);
1124
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1125
+ const head = buffer.toString("utf8", 0, bytesRead);
1126
+ for (const line of head.split("\n")) {
1127
+ const trimmed = line.trim();
1128
+ if (!trimmed) continue;
1129
+ try {
1130
+ const message = JSON.parse(trimmed);
1131
+ if (message.role !== "user") continue;
1132
+ const text = typeof message.content === "string" ? message.content : message.content.filter((part) => part.type === "text").map((part) => part.type === "text" ? part.text : "").join(" ");
1133
+ const collapsed = text.replace(/\s+/g, " ").trim();
1134
+ if (collapsed) return collapsed;
1135
+ } catch {
1136
+ }
1137
+ }
1138
+ return null;
1139
+ } catch {
1140
+ return null;
1141
+ } finally {
1142
+ await handle?.close().catch(() => {
1143
+ });
1144
+ }
1145
+ }
1029
1146
  function shortSessionId(id) {
1030
1147
  const dash = id.lastIndexOf("-");
1031
1148
  return dash === -1 ? id : id.slice(dash + 1);
@@ -1042,20 +1159,20 @@ async function resolveSessionId(query) {
1042
1159
  function relativeTime(timestamp) {
1043
1160
  const diff = Date.now() - timestamp;
1044
1161
  const minutes = Math.floor(diff / 6e4);
1045
- if (minutes < 1) return "\u521A\u521A";
1046
- if (minutes < 60) return `${minutes} \u5206\u949F\u524D`;
1162
+ if (minutes < 1) return "just now";
1163
+ if (minutes < 60) return `${minutes}m ago`;
1047
1164
  const hours = Math.floor(minutes / 60);
1048
- if (hours < 24) return `${hours} \u5C0F\u65F6\u524D`;
1165
+ if (hours < 24) return `${hours}h ago`;
1049
1166
  const days = Math.floor(hours / 24);
1050
- if (days < 30) return `${days} \u5929\u524D`;
1167
+ if (days < 30) return `${days}d ago`;
1051
1168
  const months = Math.floor(days / 30);
1052
- if (months < 12) return `${months} \u4E2A\u6708\u524D`;
1053
- return `${Math.floor(months / 12)} \u5E74\u524D`;
1169
+ if (months < 12) return `${months}mo ago`;
1170
+ return `${Math.floor(months / 12)}y ago`;
1054
1171
  }
1055
1172
  function formatSessionEntries(entries, options = {}) {
1056
1173
  return entries.map(({ meta, messageCount }) => {
1057
- const title = meta.title || "(\u65E0\u6807\u9898)";
1058
- const base = `${shortSessionId(meta.id)} ${title} ${messageCount} \u6761\u6D88\u606F \u66F4\u65B0\u4E8E ${relativeTime(meta.updatedAt)}`;
1174
+ const title = meta.title || "(untitled)";
1175
+ const base = `${shortSessionId(meta.id)} ${title} ${messageCount} messages updated ${relativeTime(meta.updatedAt)}`;
1059
1176
  return options.showCwd ? `${base} [${meta.cwd}]` : base;
1060
1177
  }).join("\n");
1061
1178
  }
@@ -1086,7 +1203,9 @@ function computeCostUsd(usage, model) {
1086
1203
  if (model?.promptPrice === void 0 || model.completionPrice === void 0) {
1087
1204
  return null;
1088
1205
  }
1089
- return (usage.promptTokens * model.promptPrice + usage.completionTokens * model.completionPrice) / 1e6;
1206
+ const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
1207
+ const promptUsd = (usage.promptTokens - cached) * model.promptPrice + cached * (model.cacheReadPrice ?? model.promptPrice) + (usage.cacheReadInputTokens ?? 0) * (model.cacheReadPrice ?? 0);
1208
+ return (promptUsd + usage.completionTokens * model.completionPrice) / 1e6;
1090
1209
  }
1091
1210
  function estimateCost(usage, modelName, model) {
1092
1211
  const cost = computeCostUsd(usage, model);
@@ -1102,7 +1221,8 @@ function estimateCost(usage, modelName, model) {
1102
1221
  }
1103
1222
  return `Estimated cost: unknown (no price configured for ${modelName})`;
1104
1223
  }
1105
- return `Estimated cost: $${formatDollars(cost)} (${model?.name} @ $${model?.promptPrice}/M prompt, $${model?.completionPrice}/M completion)`;
1224
+ const cachePrice = model?.cacheReadPrice !== void 0 ? `, $${model.cacheReadPrice}/M cache read` : "";
1225
+ return `Estimated cost: $${formatDollars(cost)} (${model?.name} @ $${model?.promptPrice}/M prompt, $${model?.completionPrice}/M completion${cachePrice})`;
1106
1226
  }
1107
1227
 
1108
1228
  // src/session/usage.ts
@@ -1178,6 +1298,12 @@ function aggregateUsage(metas) {
1178
1298
  entry.sessions += 1;
1179
1299
  entry.requests += requestCount;
1180
1300
  addTo(entry, totals);
1301
+ if (hasTotals && meta.usage) {
1302
+ const cached = tokens(meta.usage.cachedPromptTokens);
1303
+ const cacheRead = tokens(meta.usage.cacheReadInputTokens);
1304
+ if (cached > 0) entry.cachedPromptTokens = (entry.cachedPromptTokens ?? 0) + cached;
1305
+ if (cacheRead > 0) entry.cacheReadInputTokens = (entry.cacheReadInputTokens ?? 0) + cacheRead;
1306
+ }
1181
1307
  models.set(name, entry);
1182
1308
  }
1183
1309
  return {
@@ -1232,7 +1358,7 @@ function formatUsageDashboard(stats, models, options = {}) {
1232
1358
  const configured = new Map(models.map((m) => [m.name, m]));
1233
1359
  const rows = stats.models.map((entry) => {
1234
1360
  const cfg = configured.get(entry.model);
1235
- const cost = cfg?.promptPrice !== void 0 && cfg.completionPrice !== void 0 ? (entry.promptTokens * cfg.promptPrice + entry.completionTokens * cfg.completionPrice) / 1e6 : null;
1361
+ const cost = computeCostUsd(entry, cfg);
1236
1362
  return { entry, cost };
1237
1363
  });
1238
1364
  const header = ["model", "requests", "prompt", "completion", "total", "cost"];
@@ -1306,7 +1432,7 @@ function cacheHitPercent(totals) {
1306
1432
  // src/cli/clipboard.ts
1307
1433
  import { execFile as execFile2 } from "child_process";
1308
1434
  import { randomUUID as randomUUID2 } from "crypto";
1309
- import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1435
+ import { readFile as readFile3, stat as stat2, unlink as unlink2 } from "fs/promises";
1310
1436
  import os2 from "os";
1311
1437
  import path9 from "path";
1312
1438
  import { promisify as promisify2 } from "util";
@@ -1385,6 +1511,13 @@ async function finalizeClipboardImage(image) {
1385
1511
  const result = await downsampleImageIfNeeded(image);
1386
1512
  return { image: result.image, oversized: result.oversized && !result.resized };
1387
1513
  }
1514
+ async function readClipboardTempFile(tempFile) {
1515
+ const st = await stat2(tempFile).catch(() => null);
1516
+ if (!st || st.size === 0 || st.size > MAX_CLIPBOARD_IMAGE_BYTES) return null;
1517
+ const buf = await readFile3(tempFile).catch(() => null);
1518
+ if (!buf || buf.length === 0) return null;
1519
+ return { path: "clipboard.png", mimeType: "image/png", data: buf.toString("base64") };
1520
+ }
1388
1521
  async function readClipboardImage() {
1389
1522
  const tempFile = path9.join(os2.tmpdir(), `star-clipboard-${randomUUID2()}.png`);
1390
1523
  const hasPngpaste = process.platform === "darwin" ? await commandExists2("pngpaste") : false;
@@ -1397,22 +1530,18 @@ async function readClipboardImage() {
1397
1530
  maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
1398
1531
  encoding: "buffer"
1399
1532
  });
1400
- const buf2 = stdout;
1401
- if (buf2.length === 0) return null;
1533
+ const buf = stdout;
1534
+ if (buf.length === 0) return null;
1402
1535
  return finalizeClipboardImage({
1403
1536
  path: "clipboard.png",
1404
1537
  mimeType: "image/png",
1405
- data: buf2.toString("base64")
1538
+ data: buf.toString("base64")
1406
1539
  });
1407
1540
  }
1408
1541
  await execFileAsync2(plan.command, plan.args, { timeout: CLIPBOARD_TIMEOUT_MS });
1409
- const buf = await readFile3(tempFile).catch(() => null);
1410
- if (!buf || buf.length === 0) return null;
1411
- return finalizeClipboardImage({
1412
- path: "clipboard.png",
1413
- mimeType: "image/png",
1414
- data: buf.toString("base64")
1415
- });
1542
+ const image = await readClipboardTempFile(tempFile);
1543
+ if (!image) return null;
1544
+ return finalizeClipboardImage(image);
1416
1545
  } catch {
1417
1546
  return null;
1418
1547
  } finally {
@@ -1455,10 +1584,10 @@ async function compactSession(opts) {
1455
1584
  };
1456
1585
  }
1457
1586
  const beforeTokens = estimateTokens(messages);
1458
- const compacted = compactMessages(messages, config.contextMaxTokens);
1587
+ const compacted = compactMessages(messages, config.contextMaxTokens, { force: true });
1459
1588
  if (!compacted.compacted) {
1460
1589
  return {
1461
- message: `Nothing to compact: ~${beforeTokens} estimated tokens, below the limit of ${config.contextMaxTokens}.`,
1590
+ message: `Nothing to compact: history is too short to drop a whole turn (~${beforeTokens} estimated tokens).`,
1462
1591
  compacted: false
1463
1592
  };
1464
1593
  }
@@ -1772,7 +1901,7 @@ function applyEdit(content, oldString, newString, replaceAll) {
1772
1901
  }
1773
1902
  if (count === 0) return null;
1774
1903
  if (count > 1 && !replaceAll) return null;
1775
- return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
1904
+ return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
1776
1905
  }
1777
1906
  function newFilePreview(content, label) {
1778
1907
  const all = content.split("\n");
@@ -2071,13 +2200,22 @@ ${content}`);
2071
2200
  });
2072
2201
  register({
2073
2202
  name: "undo",
2074
- description: "Undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages",
2203
+ description: "Undo the last conversation turn: revert its file changes (any tool, bash included, when git snapshots are on) and retract its messages",
2075
2204
  usage: "/undo",
2076
2205
  category: "Changes",
2077
2206
  async run(_args, ctx) {
2078
2207
  ctx.addSystemMessage(await ctx.undo());
2079
2208
  }
2080
2209
  });
2210
+ register({
2211
+ name: "redo",
2212
+ description: "Redo the last git-snapshot undo: restore the working tree to its pre-undo state",
2213
+ usage: "/redo",
2214
+ category: "Changes",
2215
+ async run(_args, ctx) {
2216
+ ctx.addSystemMessage(await ctx.redo());
2217
+ }
2218
+ });
2081
2219
  register({
2082
2220
  name: "rewind",
2083
2221
  description: "List file-change checkpoints, or rewind to just before one: restore files and retract the conversation",
@@ -2349,19 +2487,19 @@ function maskApiKey(key) {
2349
2487
  if (key.length <= 8) return "\u2022\u2022\u2022\u2022\u2022\u2022";
2350
2488
  return `${key.slice(0, 3)}\u2026${key.slice(-4)}`;
2351
2489
  }
2352
- function tomlString(value) {
2490
+ function tomlString2(value) {
2353
2491
  return JSON.stringify(value);
2354
2492
  }
2355
2493
  function buildProviderTomlBlock(provider) {
2356
2494
  return [
2357
2495
  "[[providers]]",
2358
- `name = ${tomlString(provider.name)}`,
2496
+ `name = ${tomlString2(provider.name)}`,
2359
2497
  "# wire format: openai-compatible | anthropic | openai-responses",
2360
- `protocol = ${tomlString(provider.protocol)}`,
2361
- `baseURL = ${tomlString(provider.baseURL)}`,
2498
+ `protocol = ${tomlString2(provider.protocol)}`,
2499
+ `baseURL = ${tomlString2(provider.baseURL)}`,
2362
2500
  "# environment variable holding the API key; the key itself lives in",
2363
2501
  "# ~/.star-cli/.env (written by /connect), never in this file",
2364
- `apiKeyEnv = ${tomlString(provider.apiKeyEnv ?? "")}`,
2502
+ `apiKeyEnv = ${tomlString2(provider.apiKeyEnv ?? "")}`,
2365
2503
  "# inline key instead of apiKeyEnv (not recommended):",
2366
2504
  '# apiKey = "sk-..."',
2367
2505
  "# extra HTTP headers sent to the provider:",
@@ -2373,9 +2511,9 @@ function buildProviderTomlBlock(provider) {
2373
2511
  function buildModelTomlBlock(model) {
2374
2512
  return [
2375
2513
  "[[models]]",
2376
- `name = ${tomlString(model.name)}`,
2377
- `provider = ${tomlString(model.provider)}`,
2378
- `model = ${tomlString(model.model)}`,
2514
+ `name = ${tomlString2(model.name)}`,
2515
+ `provider = ${tomlString2(model.provider)}`,
2516
+ `model = ${tomlString2(model.model)}`,
2379
2517
  "# per-model context window in tokens \u2014 overrides the top-level",
2380
2518
  "# contextMaxTokens for compaction and the ctx % in the status bar",
2381
2519
  `contextMaxTokens = ${model.contextMaxTokens ?? CONNECT_DEFAULT_CONTEXT_TOKENS}`,
@@ -2443,7 +2581,7 @@ function saveConnection(answers, configPath = globalConfigPath(), envPath = envF
2443
2581
  return { provider, model, configPath, envPath };
2444
2582
  }
2445
2583
  function setDefaultModelInToml(content, modelName) {
2446
- const line = `defaultModel = ${tomlString(modelName)}`;
2584
+ const line = `defaultModel = ${tomlString2(modelName)}`;
2447
2585
  const lines = content.split("\n");
2448
2586
  let inTopLevel = true;
2449
2587
  for (let i = 0; i < lines.length; i++) {
@@ -2883,6 +3021,20 @@ async function initProject(opts) {
2883
3021
  };
2884
3022
  }
2885
3023
 
3024
+ // src/cli/commands/redo.ts
3025
+ function formatRedoSummary(entry) {
3026
+ const label = entry.label !== "" ? ` of "${entry.label}"` : "";
3027
+ return `Restore the working tree to its state before the undo${label} (tree ${entry.tree.slice(0, 8)}). Conversation messages are not restored.`;
3028
+ }
3029
+ function formatRedoResult(result) {
3030
+ if (!result.ok) {
3031
+ return `Redo failed \u2014 git could not restore the snapshot (tree ${result.tree.slice(0, 8)}). The redo entry was kept; try again or check that git is working.`;
3032
+ }
3033
+ const files = result.files === null ? "unknown number of" : `${result.files}`;
3034
+ const label = result.label !== "" ? ` \u2014 "${result.label}"` : "";
3035
+ return `Redone: restored ${files} file(s) to the pre-undo state (tree ${result.tree.slice(0, 8)})${label}.`;
3036
+ }
3037
+
2886
3038
  // src/cli/commands/registry.ts
2887
3039
  var CommandRegistry = class {
2888
3040
  commands = /* @__PURE__ */ new Map();
@@ -2998,6 +3150,16 @@ async function buildUndoDiffs(snapshots, cwd) {
2998
3150
  }
2999
3151
  return diffs;
3000
3152
  }
3153
+ async function buildUndoTreeDiffs(cwd, tree) {
3154
+ const names = await diffTreeNames(cwd, tree);
3155
+ if (!names || names.length === 0) return [];
3156
+ return [
3157
+ {
3158
+ label: "Working-tree files restoring the turn's start will touch:",
3159
+ lines: names.map((name) => ({ kind: "marker", text: name }))
3160
+ }
3161
+ ];
3162
+ }
3001
3163
 
3002
3164
  // src/cli/components/ConnectWizard.tsx
3003
3165
  import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
@@ -3932,18 +4094,19 @@ import { memo } from "react";
3932
4094
  import { Text as Text6 } from "ink";
3933
4095
  import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3934
4096
  function DiffLineView({ line }) {
4097
+ const text = toTerminalSafe(line.text);
3935
4098
  if (line.kind === "add") return /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
3936
4099
  "+ ",
3937
- line.text
4100
+ text
3938
4101
  ] });
3939
4102
  if (line.kind === "del") return /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
3940
4103
  "- ",
3941
- line.text
4104
+ text
3942
4105
  ] });
3943
- if (line.kind === "marker") return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: line.text });
4106
+ if (line.kind === "marker") return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: text });
3944
4107
  return /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
3945
4108
  " ",
3946
- line.text
4109
+ text
3947
4110
  ] });
3948
4111
  }
3949
4112
  function DiffLines({ lines }) {
@@ -3987,6 +4150,17 @@ var MessageList = memo(function MessageList2({ messages }) {
3987
4150
  // src/cli/components/PermissionPrompt.tsx
3988
4151
  import { Box as Box7, Text as Text8, useInput as useInput6 } from "ink";
3989
4152
  import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
4153
+ var BASH_COMMAND_MAX = 1e3;
4154
+ function middleTruncate(text, max) {
4155
+ if (text.length <= max) return text;
4156
+ const half = Math.floor((max - 1) / 2);
4157
+ return `${text.slice(0, half)}\u2026${text.slice(text.length - half)}`;
4158
+ }
4159
+ function bashCommand(args) {
4160
+ if (typeof args !== "object" || args === null) return null;
4161
+ const command = args.command;
4162
+ return typeof command === "string" ? command : null;
4163
+ }
3990
4164
  function PermissionPrompt({ request, preview, onDecision }) {
3991
4165
  useInput6((input) => {
3992
4166
  const ch = input.toLowerCase();
@@ -3994,6 +4168,7 @@ function PermissionPrompt({ request, preview, onDecision }) {
3994
4168
  else if (ch === "n") onDecision("no");
3995
4169
  else if (ch === "a") onDecision("always");
3996
4170
  });
4171
+ const command = request.toolName === "bash" ? bashCommand(request.args) : null;
3997
4172
  return /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3998
4173
  /* @__PURE__ */ jsxs8(Text8, { bold: true, color: "yellow", children: [
3999
4174
  "Permission required: ",
@@ -4005,10 +4180,10 @@ function PermissionPrompt({ request, preview, onDecision }) {
4005
4180
  preview ? /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", children: [
4006
4181
  /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4007
4182
  preview.type === "new-file" ? "New file: " : "",
4008
- preview.label
4183
+ toTerminalSafe(preview.label)
4009
4184
  ] }),
4010
4185
  /* @__PURE__ */ jsx8(DiffLines, { lines: preview.lines })
4011
- ] }) : /* @__PURE__ */ jsx8(Text8, { children: summarizeArgs(request.args) }),
4186
+ ] }) : command !== null ? /* @__PURE__ */ jsx8(Text8, { children: toTerminalSafe(middleTruncate(command, BASH_COMMAND_MAX)) }) : /* @__PURE__ */ jsx8(Text8, { children: toTerminalSafe(summarizeArgs(request.args)) }),
4012
4187
  /* @__PURE__ */ jsx8(Text8, { children: "[y] allow [n] deny [a] always (saved to config)" })
4013
4188
  ] });
4014
4189
  }
@@ -4067,7 +4242,7 @@ var StatusBar = memo2(function StatusBar2({
4067
4242
  }) {
4068
4243
  const { stdout } = useStdout2();
4069
4244
  const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
4070
- const joined = backgroundTasks.join(", ");
4245
+ const joined = backgroundTasks.map((label) => toTerminalSafe(label)).join(", ");
4071
4246
  const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
4072
4247
  return /* @__PURE__ */ jsxs10(Box9, { justifyContent: "space-between", children: [
4073
4248
  /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
@@ -4156,17 +4331,6 @@ function ThinkingIndicator({
4156
4331
 
4157
4332
  // src/cli/components/TodoPanel.tsx
4158
4333
  import { Box as Box12, Text as Text13 } from "ink";
4159
-
4160
- // src/cli/terminal-text.ts
4161
- var UNSAFE_CHARS = /[\x00-\x09\x0b-\x1f\x7f]/;
4162
- var CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
4163
- var TABS = /\t/g;
4164
- function toTerminalSafe(text) {
4165
- if (!UNSAFE_CHARS.test(text)) return text;
4166
- return text.replace(TABS, " ").replace(CONTROL_CHARS, "");
4167
- }
4168
-
4169
- // src/cli/components/TodoPanel.tsx
4170
4334
  import { jsxs as jsxs13 } from "react/jsx-runtime";
4171
4335
  var SYMBOLS = {
4172
4336
  pending: "[ ]",
@@ -4356,6 +4520,18 @@ async function executeShellBang(raw, cwd, signal) {
4356
4520
  };
4357
4521
  }
4358
4522
 
4523
+ // src/cli/system-prompt.ts
4524
+ var SYSTEM_PROMPT = `You are Star CLI, an AI coding agent running in the user's terminal.
4525
+ You help with software engineering tasks: reading, writing and editing code, running shell commands, and managing todos.
4526
+ Be concise and direct. Use tools when they help accomplish the task.
4527
+ For any task with two or more steps, start by creating a todo list with todo_write and keep it updated as you progress; only mark an item completed after you have verified its result.
4528
+ The working directory is the user's project root; never touch files outside it without explicit instruction.
4529
+ For conversion, batch-processing, or other scriptable tasks, write a script file into the working directory and run it instead of pasting long inline code into the shell; once the script's output is verified, delete the script unless the user asked to keep it.
4530
+ When a tool call or command fails, read the error output, work out the cause, and try again with a corrected or alternative approach \u2014 never repeat an identical failing call without changing something.
4531
+ Never consider the task finished until you have verified the result yourself: run the tests, the build, or a check command that proves the output is correct, and if verification fails or reveals gaps, keep fixing until it passes.
4532
+ Do not end your turn while the task is still incomplete; keep going until it is done or you are genuinely blocked, and if you are blocked, state exactly what is missing.
4533
+ Never end a reply by announcing what you will do next \u2014 either do it now with tool calls, or do not mention it. Narrating future actions is not progress.`;
4534
+
4359
4535
  // src/cli/ticker.ts
4360
4536
  var TICK_MS = 100;
4361
4537
  function nextFlush(prev, next) {
@@ -4414,20 +4590,25 @@ function runningTaskLabels() {
4414
4590
  return [...shells, ...agents];
4415
4591
  }
4416
4592
  var SESSION_MODE_CYCLE = ["ask", "auto", "readonly", "plan"];
4593
+ var BUSY_BLOCKED_COMMANDS = /* @__PURE__ */ new Set([
4594
+ "new",
4595
+ "resume",
4596
+ "undo",
4597
+ "redo",
4598
+ "rewind",
4599
+ "compact",
4600
+ "fork",
4601
+ "clear-sessions",
4602
+ "model",
4603
+ "connect",
4604
+ "memory"
4605
+ ]);
4417
4606
  function formatUsage(usage) {
4418
4607
  return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
4419
4608
  }
4420
4609
  var SESSION_PICKER_CAP = 20;
4610
+ var SESSION_PREVIEW_MAX = 60;
4421
4611
  var MAX_VISIBLE_QUEUED = 5;
4422
- async function sessionPreview(id) {
4423
- const store = await SessionStore.open(id);
4424
- if (!store) return null;
4425
- const first = (await store.messages()).find((m) => m.role === "user");
4426
- if (!first) return null;
4427
- const text = coreMessageText(first).replace(/\s+/g, " ").trim();
4428
- if (!text) return null;
4429
- return text.length > 60 ? `${text.slice(0, 60)}\u2026` : text;
4430
- }
4431
4612
  function Repl({
4432
4613
  backend,
4433
4614
  model,
@@ -4463,12 +4644,14 @@ function Repl({
4463
4644
  const nextIdRef = useRef5(initialDisplay.length);
4464
4645
  const abortRef = useRef5(null);
4465
4646
  const streamedRef = useRef5("");
4647
+ const lastCommittedLenRef = useRef5(0);
4466
4648
  const thinkingRef = useRef5(false);
4467
4649
  const reasoningRef = useRef5("");
4468
4650
  const tickerStopRef = useRef5(null);
4469
4651
  const flushedRef = useRef5({ streamed: "", reasoning: "" });
4470
4652
  const toolCardsRef = useRef5(/* @__PURE__ */ new Map());
4471
4653
  const pendingRef = useRef5(null);
4654
+ const permissionChainRef = useRef5(Promise.resolve());
4472
4655
  const alwaysAllowedRef = useRef5(/* @__PURE__ */ new Set());
4473
4656
  const prevModeRef = useRef5(null);
4474
4657
  const planApprovalRef = useRef5(false);
@@ -4477,6 +4660,10 @@ function Repl({
4477
4660
  const pendingConnectRef = useRef5(null);
4478
4661
  const modelNameRef = useRef5(model);
4479
4662
  const sessionStoreRef = useRef5(sessionStore);
4663
+ const configRef = useRef5(config);
4664
+ useEffect2(() => {
4665
+ configRef.current = config;
4666
+ }, [config]);
4480
4667
  const usageRef = useRef5(initialUsage ? { ...initialUsage } : emptyUsage());
4481
4668
  const turnChunksRef = useRef5(0);
4482
4669
  const messagesRef = useRef5(initialDisplay);
@@ -4652,14 +4839,22 @@ function Repl({
4652
4839
  );
4653
4840
  const attachConfirmHandler = useCallback(
4654
4841
  (target) => {
4655
- target.confirmHandler = async (req) => {
4842
+ target.confirmHandler = (req) => {
4656
4843
  if (isAllowedByRules([...alwaysAllowedRef.current], req)) {
4657
- return true;
4844
+ return Promise.resolve(true);
4658
4845
  }
4659
- const preview = await generateDiffPreview(req.toolName, req.args, cwd).catch(() => null);
4660
- return new Promise((resolve) => {
4661
- setPendingPermission({ request: req, preview, resolve });
4662
- });
4846
+ const ask = async () => {
4847
+ const preview = await generateDiffPreview(req.toolName, req.args, cwd).catch(() => null);
4848
+ return new Promise((resolve) => {
4849
+ setPendingPermission({ request: req, preview, resolve });
4850
+ });
4851
+ };
4852
+ const decision = permissionChainRef.current.then(ask);
4853
+ permissionChainRef.current = decision.then(
4854
+ () => void 0,
4855
+ () => void 0
4856
+ );
4857
+ return decision;
4663
4858
  };
4664
4859
  target.onHookWarning = (message) => pushMessage("system", message);
4665
4860
  },
@@ -4690,27 +4885,25 @@ function Repl({
4690
4885
  setBgLabels(runningTaskLabels());
4691
4886
  pushMessage(
4692
4887
  "system",
4693
- task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
4888
+ toTerminalSafe(
4889
+ task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
4890
+ )
4694
4891
  );
4695
4892
  if (task.status !== "running") {
4696
4893
  notifyBell({
4697
- enabled: config.notifyBell,
4698
- thresholdSec: config.notifyBellThresholdSec,
4894
+ enabled: configRef.current.notifyBell,
4895
+ thresholdSec: configRef.current.notifyBellThresholdSec,
4699
4896
  noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
4700
4897
  });
4701
4898
  }
4702
4899
  };
4703
4900
  const onAgentUpdate = (task) => {
4704
4901
  setBgLabels(runningTaskLabels());
4705
- const label = task.description ? `: ${task.description}` : "";
4706
- pushMessage(
4707
- "system",
4708
- task.status === "running" ? `Background subagent ${task.id} started${label}` : `Background subagent ${task.id} ${task.status}${label}`
4709
- );
4902
+ pushMessage("system", toTerminalSafe(formatAgentTaskUpdate(task)));
4710
4903
  if (task.status !== "running") {
4711
4904
  notifyBell({
4712
- enabled: config.notifyBell,
4713
- thresholdSec: config.notifyBellThresholdSec,
4905
+ enabled: configRef.current.notifyBell,
4906
+ thresholdSec: configRef.current.notifyBellThresholdSec,
4714
4907
  noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
4715
4908
  });
4716
4909
  }
@@ -4723,7 +4916,7 @@ function Repl({
4723
4916
  defaultTaskManager.cleanup();
4724
4917
  defaultAgentTasks.cleanup();
4725
4918
  };
4726
- }, [pushMessage, config]);
4919
+ }, [pushMessage]);
4727
4920
  const interrupt = useCallback(() => {
4728
4921
  abortRef.current?.abort();
4729
4922
  const p = pendingRef.current;
@@ -4762,9 +4955,11 @@ function Repl({
4762
4955
  const handleExit = useCallback(() => {
4763
4956
  const killed = defaultTaskManager.cleanup();
4764
4957
  const killedAgents = defaultAgentTasks.cleanup();
4765
- const stopped = [...killed.map((t) => t.id), ...killedAgents.map((t) => t.id)];
4766
- if (stopped.length > 0) {
4767
- pushMessage("system", `Stopped ${stopped.length} background task(s): ${stopped.join(", ")}`);
4958
+ const stopped = killed.length + killedAgents.length;
4959
+ if (stopped > 0) {
4960
+ pushMessage("system", `${stopped} background task(s) still running; reports will be lost.`);
4961
+ setTimeout(exit, 50);
4962
+ return;
4768
4963
  }
4769
4964
  exit();
4770
4965
  }, [exit, pushMessage]);
@@ -4825,6 +5020,7 @@ function Repl({
4825
5020
  registry: createDefaultRegistry(),
4826
5021
  config,
4827
5022
  cwd,
5023
+ system: SYSTEM_PROMPT,
4828
5024
  sessionStore: sessionStoreRef.current,
4829
5025
  contextMaxTokens: contextWindowTokens(config, name)
4830
5026
  });
@@ -4866,12 +5062,15 @@ function Repl({
4866
5062
  if (!resumed) {
4867
5063
  return `Session not found: ${id}`;
4868
5064
  }
4869
- await current.loadMessages(resumed.messages);
4870
- setTodos(await loadTodos(cwd));
4871
5065
  const store = await SessionStore.open(resolvedId);
4872
- if (store) {
4873
- hydrateSnapshots(await loadSessionSnapshots(store.dir));
5066
+ if (!store) {
5067
+ return `Session not found: ${id}`;
4874
5068
  }
5069
+ await current.loadMessages(resumed.messages);
5070
+ current.setSessionStore(store);
5071
+ sessionStoreRef.current = store;
5072
+ setTodos(await loadTodos(cwd));
5073
+ hydrateSnapshots(await loadSessionSnapshots(store.dir));
4875
5074
  const display = buildDisplayMessages(resumed.messages);
4876
5075
  nextIdRef.current = display.length;
4877
5076
  applyMessages(display);
@@ -4897,6 +5096,7 @@ function Repl({
4897
5096
  abortRef.current = controller;
4898
5097
  const turnStartedAt = Date.now();
4899
5098
  streamedRef.current = "";
5099
+ lastCommittedLenRef.current = 0;
4900
5100
  thinkingRef.current = true;
4901
5101
  reasoningRef.current = "";
4902
5102
  turnChunksRef.current = 0;
@@ -4907,22 +5107,25 @@ function Repl({
4907
5107
  setIsStreaming(true);
4908
5108
  flushedRef.current = { streamed: "", reasoning: "" };
4909
5109
  const commitStreamed = (tight) => {
4910
- if (streamedRef.current.length === 0) return;
4911
- pushAssistantChunk(streamedRef.current, tight);
5110
+ const uncommitted = streamedRef.current.slice(lastCommittedLenRef.current);
5111
+ if (uncommitted.length > 0) {
5112
+ pushAssistantChunk(uncommitted, tight);
5113
+ }
4912
5114
  streamedRef.current = "";
5115
+ lastCommittedLenRef.current = 0;
4913
5116
  flushedRef.current = { ...flushedRef.current, streamed: "" };
4914
5117
  setStreamingText("");
4915
5118
  };
4916
5119
  tickerStopRef.current = startTicker((tick) => {
4917
5120
  setSpinnerTick(tick);
4918
- const split = splitCommittableLines(streamedRef.current, 8);
5121
+ const uncommitted = streamedRef.current.slice(lastCommittedLenRef.current);
5122
+ const split = splitCommittableLines(uncommitted, 8);
4919
5123
  if (split) {
4920
5124
  pushAssistantChunk(split.committed, true);
4921
- streamedRef.current = split.rest;
4922
- flushedRef.current = { ...flushedRef.current, streamed: "" };
5125
+ lastCommittedLenRef.current += split.committed.length + 1;
4923
5126
  }
4924
5127
  const next = {
4925
- streamed: streamedRef.current,
5128
+ streamed: streamedRef.current.slice(lastCommittedLenRef.current),
4926
5129
  reasoning: reasoningRef.current
4927
5130
  };
4928
5131
  if (nextFlush(flushedRef.current, next) !== null) {
@@ -4991,6 +5194,12 @@ function Repl({
4991
5194
  usage.promptTokens += event.usage.promptTokens;
4992
5195
  usage.completionTokens += event.usage.completionTokens;
4993
5196
  usage.totalTokens += event.usage.totalTokens;
5197
+ if (event.usage.cachedPromptTokens) {
5198
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + event.usage.cachedPromptTokens;
5199
+ }
5200
+ if (event.usage.cacheReadInputTokens) {
5201
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + event.usage.cacheReadInputTokens;
5202
+ }
4994
5203
  if (cacheUsageReported(event.usage)) {
4995
5204
  if (!usage.cache) usage.cache = { cachedTokens: 0, promptTokens: 0 };
4996
5205
  addToCacheTotals(usage.cache, event.usage);
@@ -5000,6 +5209,9 @@ function Repl({
5000
5209
  });
5001
5210
  }
5002
5211
  } else if (event.type === "retry") {
5212
+ streamedRef.current = streamedRef.current.slice(0, lastCommittedLenRef.current);
5213
+ flushedRef.current = { ...flushedRef.current, streamed: "" };
5214
+ setStreamingText("");
5003
5215
  const wait = event.delayMs !== void 0 && event.delayMs >= 1e3 ? ` in ${Math.round(event.delayMs / 1e3)}s` : "";
5004
5216
  pushMessage(
5005
5217
  "system",
@@ -5030,8 +5242,9 @@ function Repl({
5030
5242
  setThinkingText("");
5031
5243
  setThoughtSummary(null);
5032
5244
  setActivity(null);
5033
- const finalText = streamedRef.current;
5245
+ const finalText = streamedRef.current.slice(lastCommittedLenRef.current);
5034
5246
  streamedRef.current = "";
5247
+ lastCommittedLenRef.current = 0;
5035
5248
  setStreamingText(null);
5036
5249
  const interrupted = controller.signal.aborted;
5037
5250
  notifyBell({
@@ -5214,10 +5427,8 @@ ${lines.join("\n")}`;
5214
5427
  return ctx.permissionMode(picked);
5215
5428
  },
5216
5429
  pickSession: async (all) => {
5217
- const entries = (await listSessionEntries(all ? void 0 : cwd)).slice(
5218
- 0,
5219
- SESSION_PICKER_CAP
5220
- );
5430
+ const currentId = sessionStoreRef.current?.id;
5431
+ const entries = (await listSessionEntries(all ? void 0 : cwd)).filter((entry) => entry.meta.id !== currentId).slice(0, SESSION_PICKER_CAP);
5221
5432
  if (entries.length === 0) {
5222
5433
  return all ? "No sessions found." : "No sessions found for this directory.";
5223
5434
  }
@@ -5225,13 +5436,16 @@ ${lines.join("\n")}`;
5225
5436
  entries.map(async ({ meta, messageCount }) => {
5226
5437
  const parts = [`${messageCount} messages \xB7 ${relativeTime(meta.updatedAt)}`];
5227
5438
  const preview = await sessionPreview(meta.id);
5228
- if (preview) parts.push(preview);
5439
+ if (preview) {
5440
+ parts.push(
5441
+ preview.length > SESSION_PREVIEW_MAX ? `${preview.slice(0, SESSION_PREVIEW_MAX)}\u2026` : preview
5442
+ );
5443
+ }
5229
5444
  if (all) parts.push(`[${meta.cwd}]`);
5230
5445
  return {
5231
5446
  value: meta.id,
5232
5447
  label: meta.title || shortSessionId(meta.id),
5233
- description: parts.join(" \xB7 "),
5234
- hint: meta.id === sessionStoreRef.current?.id ? "current" : void 0
5448
+ description: parts.join(" \xB7 ")
5235
5449
  };
5236
5450
  })
5237
5451
  );
@@ -5316,18 +5530,26 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5316
5530
  } catch {
5317
5531
  summaryModel = null;
5318
5532
  }
5319
- const result = await compactSession({
5320
- backend: backendRef.current,
5321
- sessionStore: sessionStoreRef.current,
5322
- config,
5323
- model: summaryModel
5324
- });
5325
- if (result.compacted && result.messages) {
5326
- const display = buildDisplayMessages(result.messages);
5327
- nextIdRef.current = display.length;
5328
- applyMessages(display);
5533
+ tickerStopRef.current = startTicker((tick) => setSpinnerTick(tick));
5534
+ setActivity("compacting context\u2026");
5535
+ try {
5536
+ const result = await compactSession({
5537
+ backend: backendRef.current,
5538
+ sessionStore: sessionStoreRef.current,
5539
+ config,
5540
+ model: summaryModel
5541
+ });
5542
+ if (result.compacted && result.messages) {
5543
+ const display = buildDisplayMessages(result.messages);
5544
+ nextIdRef.current = display.length;
5545
+ applyMessages(display);
5546
+ }
5547
+ return result.message;
5548
+ } finally {
5549
+ tickerStopRef.current?.();
5550
+ tickerStopRef.current = null;
5551
+ setActivity(null);
5329
5552
  }
5330
- return result.message;
5331
5553
  },
5332
5554
  exportSession: (arg) => exportSession({
5333
5555
  backend: backendRef.current,
@@ -5344,15 +5566,16 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5344
5566
  if (preview.removed === 0) {
5345
5567
  return "Nothing to undo (no conversation turn to retract).";
5346
5568
  }
5347
- const diffs = await buildUndoDiffs(
5569
+ const diffs = preview.tree ? await buildUndoTreeDiffs(cwd, preview.tree) : await buildUndoDiffs(
5348
5570
  preview.turn !== void 0 ? listTurnSnapshots(preview.turn) : [],
5349
5571
  cwd
5350
5572
  );
5573
+ const summary = preview.tree ? `${preview.removed} message(s) will be retracted, and the working tree restored to the turn's start (git snapshot \u2014 covers changes from any tool, bash included).` : `${preview.removed} message(s) will be retracted, ${diffs.length} file change(s) reverted.`;
5351
5574
  const confirmed = await new Promise((resolve) => {
5352
5575
  setPendingRewind({
5353
5576
  title: "Undo last turn",
5354
5577
  confirmLabel: "undo",
5355
- summary: `${preview.removed} message(s) will be retracted, ${diffs.length} file change(s) reverted.`,
5578
+ summary,
5356
5579
  diffs,
5357
5580
  resolve
5358
5581
  });
@@ -5360,11 +5583,10 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5360
5583
  if (!confirmed) {
5361
5584
  return "Undo cancelled.";
5362
5585
  }
5363
- const { removed, turn } = await current.retractLastTurn();
5364
- if (removed === 0) {
5586
+ const outcome = await current.undoLastTurn();
5587
+ if (outcome.removed === 0) {
5365
5588
  return "Nothing to undo (no conversation turn to retract).";
5366
5589
  }
5367
- const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
5368
5590
  const visible = messagesRef.current;
5369
5591
  let cut = -1;
5370
5592
  for (let i = visible.length - 1; i >= 0; i--) {
@@ -5374,9 +5596,36 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5374
5596
  }
5375
5597
  }
5376
5598
  redrawMessages(cut >= 0 ? visible.slice(0, cut) : visible);
5377
- return [...reverted, `Retracted the last conversation turn (${removed} messages).`].join(
5378
- "\n"
5379
- );
5599
+ return [
5600
+ ...outcome.reverted,
5601
+ `Retracted the last conversation turn (${outcome.removed} messages).`
5602
+ ].join("\n");
5603
+ },
5604
+ redo: async () => {
5605
+ const current = backendRef.current;
5606
+ if (!(current instanceof AgentLoop)) {
5607
+ return "Nothing to redo.";
5608
+ }
5609
+ const entry = current.peekRedo();
5610
+ if (!entry) {
5611
+ return "Nothing to redo.";
5612
+ }
5613
+ const confirmed = await new Promise((resolve) => {
5614
+ setPendingRewind({
5615
+ title: "Redo last undo",
5616
+ confirmLabel: "redo",
5617
+ summary: formatRedoSummary(entry),
5618
+ resolve
5619
+ });
5620
+ });
5621
+ if (!confirmed) {
5622
+ return "Redo cancelled.";
5623
+ }
5624
+ const result = await current.redoLastUndo();
5625
+ if (!result) {
5626
+ return "Nothing to redo.";
5627
+ }
5628
+ return formatRedoResult(result);
5380
5629
  },
5381
5630
  rewind: async (args) => {
5382
5631
  if (!args) {
@@ -5472,6 +5721,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5472
5721
  `models (${config.models.length}): ${config.models.map((m) => m.name).join(", ") || "(none)"}`,
5473
5722
  `maxSteps: ${config.maxSteps}`,
5474
5723
  `contextMaxTokens: ${config.contextMaxTokens}`,
5724
+ `compactThresholdTokens: ${config.compactThresholdTokens ?? "(context window)"}`,
5475
5725
  `streamIdleTimeoutSec: ${config.streamIdleTimeoutSec}`,
5476
5726
  `streamFirstChunkTimeoutSec: ${config.streamFirstChunkTimeoutSec}`,
5477
5727
  `streamMaxRetries: ${config.streamMaxRetries}`,
@@ -5559,6 +5809,10 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5559
5809
  );
5560
5810
  return;
5561
5811
  }
5812
+ if (abortRef.current && BUSY_BLOCKED_COMMANDS.has(parsed.name)) {
5813
+ pushMessage("system", "Busy \u2014 wait for the turn to finish or press Esc to interrupt it.");
5814
+ return;
5815
+ }
5562
5816
  void command.run(parsed.args, registry.ctx);
5563
5817
  return;
5564
5818
  }
@@ -5741,6 +5995,38 @@ function parseTomlFile(content, filePath) {
5741
5995
  function mergeConfig(base, override) {
5742
5996
  return { ...base, ...override };
5743
5997
  }
5998
+ var PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
5999
+ "defaultModel",
6000
+ "models",
6001
+ "maxSteps",
6002
+ "contextMaxTokens",
6003
+ "compactThresholdTokens",
6004
+ "streamIdleTimeoutSec",
6005
+ "streamFirstChunkTimeoutSec",
6006
+ "streamMaxRetries",
6007
+ "maxAutoContinues",
6008
+ "contextCompaction",
6009
+ "sessionBudgetUsd",
6010
+ "notifyBell",
6011
+ "notifyBellThresholdSec",
6012
+ "doomLoopThreshold",
6013
+ "gitSnapshots"
6014
+ ]);
6015
+ function sanitizeProjectConfig(project, filePath) {
6016
+ const sanitized = {};
6017
+ for (const [key, value] of Object.entries(project)) {
6018
+ if (value === void 0) continue;
6019
+ if (PROJECT_ALLOWED_KEYS.has(key)) {
6020
+ sanitized[key] = value;
6021
+ } else {
6022
+ process.stderr.write(
6023
+ `[star] ignoring "${key}" in ${filePath}: project config cannot set this key
6024
+ `
6025
+ );
6026
+ }
6027
+ }
6028
+ return sanitized;
6029
+ }
5744
6030
  function applyOverrides(config, overrides) {
5745
6031
  if (!overrides) return config;
5746
6032
  const merged = { ...config };
@@ -5756,22 +6042,13 @@ function readTomlFileSync(filePath) {
5756
6042
  function loadConfigSync(cwd, overrides) {
5757
6043
  loadEnvFile();
5758
6044
  const global = readTomlFileSync(globalConfigPath());
5759
- const project = readTomlFileSync(projectConfigPath(cwd));
6045
+ const projectPath = projectConfigPath(cwd);
6046
+ const project = sanitizeProjectConfig(readTomlFileSync(projectPath), projectPath);
5760
6047
  const merged = applyOverrides(mergeConfig(global, project), overrides);
5761
6048
  return ConfigSchema.parse(merged);
5762
6049
  }
5763
6050
 
5764
6051
  // src/main.tsx
5765
- var SYSTEM_PROMPT = `You are Star CLI, an AI coding agent running in the user's terminal.
5766
- You help with software engineering tasks: reading, writing and editing code, running shell commands, and managing todos.
5767
- Be concise and direct. Use tools when they help accomplish the task.
5768
- For any task with two or more steps, start by creating a todo list with todo_write and keep it updated as you progress; only mark an item completed after you have verified its result.
5769
- The working directory is the user's project root; never touch files outside it without explicit instruction.
5770
- For conversion, batch-processing, or other scriptable tasks, write a script file into the working directory and run it instead of pasting long inline code into the shell; once the script's output is verified, delete the script unless the user asked to keep it.
5771
- When a tool call or command fails, read the error output, work out the cause, and try again with a corrected or alternative approach \u2014 never repeat an identical failing call without changing something.
5772
- Never consider the task finished until you have verified the result yourself: run the tests, the build, or a check command that proves the output is correct, and if verification fails or reveals gaps, keep fixing until it passes.
5773
- Do not end your turn while the task is still incomplete; keep going until it is done or you are genuinely blocked, and if you are blocked, state exactly what is missing.
5774
- Never end a reply by announcing what you will do next \u2014 either do it now with tool calls, or do not mention it. Narrating future actions is not progress.`;
5775
6052
  async function createLoop(config, modelName, cwd, sessionStore) {
5776
6053
  const model = createModel(config, modelName);
5777
6054
  const registry = createDefaultRegistry();
@@ -5785,7 +6062,7 @@ async function createLoop(config, modelName, cwd, sessionStore) {
5785
6062
  contextMaxTokens: contextWindowTokens(config, modelName)
5786
6063
  });
5787
6064
  }
5788
- async function printMode(loop, prompt, cwd, json, imagePaths) {
6065
+ async function printMode(loop, prompt, cwd, json, imagePaths, sessionStore) {
5789
6066
  const flagImages = [];
5790
6067
  for (const imagePath of imagePaths) {
5791
6068
  if (!imageMimeType(imagePath)) {
@@ -5854,6 +6131,10 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
5854
6131
  }
5855
6132
  case "finish":
5856
6133
  usage.add(event.usage);
6134
+ if (event.usage) {
6135
+ sessionStore?.addUsage(event.usage).catch(() => {
6136
+ });
6137
+ }
5857
6138
  break;
5858
6139
  case "retry": {
5859
6140
  if (!json) {
@@ -5903,8 +6184,15 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
5903
6184
  }
5904
6185
  }
5905
6186
  if (!json) process.stdout.write("\n");
5906
- defaultTaskManager.cleanup();
5907
- defaultAgentTasks.cleanup();
6187
+ const killedShells = defaultTaskManager.cleanup();
6188
+ const killedAgents = defaultAgentTasks.cleanup();
6189
+ const stopped = killedShells.length + killedAgents.length;
6190
+ if (stopped > 0) {
6191
+ process.stderr.write(
6192
+ `[info] ${stopped} background task(s) were still running and have been stopped.
6193
+ `
6194
+ );
6195
+ }
5908
6196
  return exitCode;
5909
6197
  }
5910
6198
  var program = new Command();
@@ -5982,12 +6270,17 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
5982
6270
  }
5983
6271
  }
5984
6272
  if (resumeId) {
5985
- resumed = await resumeSession(resumeId);
5986
- if (!resumed) {
6273
+ sessionStore = await SessionStore.open(resumeId);
6274
+ if (!sessionStore) {
5987
6275
  console.error(`Session not found: ${resumeId}`);
5988
6276
  process.exit(1);
5989
6277
  }
5990
- sessionStore = await SessionStore.open(resumeId);
6278
+ const [meta, loaded] = await Promise.all([sessionStore.meta(), sessionStore.messages()]);
6279
+ const messages = reconcileToolCalls(loaded);
6280
+ if (messages.length !== loaded.length) {
6281
+ await sessionStore.replaceMessages(messages);
6282
+ }
6283
+ resumed = { meta, messages };
5991
6284
  } else if (!opts.print) {
5992
6285
  sessionStore = await SessionStore.create(cwd, modelName);
5993
6286
  }
@@ -6011,7 +6304,8 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
6011
6304
  opts.print,
6012
6305
  cwd,
6013
6306
  Boolean(opts.json),
6014
- opts.image ?? []
6307
+ opts.image ?? [],
6308
+ sessionStore
6015
6309
  );
6016
6310
  return;
6017
6311
  }