@cryer/star-cli 0.3.12 → 0.3.14

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.
Files changed (33) hide show
  1. package/README.md +17 -5
  2. package/README.zh-CN.md +15 -5
  3. package/dist/{chunk-LSYGHLRU.js → chunk-2ISBRSBA.js} +479 -296
  4. package/dist/chunk-2ISBRSBA.js.map +1 -0
  5. package/dist/{chunk-F5RWZMQY.js → chunk-4HANB3DJ.js} +458 -221
  6. package/dist/chunk-4HANB3DJ.js.map +1 -0
  7. package/dist/chunk-4IRY44TG.js +0 -0
  8. package/dist/{chunk-Q5FJN4LN.js → chunk-54KH4JSO.js} +213 -125
  9. package/dist/chunk-54KH4JSO.js.map +1 -0
  10. package/dist/{chunk-MYHD3SZB.js → chunk-CECMCDZG.js} +43 -30
  11. package/dist/chunk-CECMCDZG.js.map +1 -0
  12. package/dist/{chunk-MVAMPVTA.js → chunk-KILMI3S5.js} +13 -5
  13. package/dist/{chunk-MVAMPVTA.js.map → chunk-KILMI3S5.js.map} +1 -1
  14. package/dist/{chunk-2GAUAYCU.js → chunk-NRLGGA7L.js} +507 -175
  15. package/dist/chunk-NRLGGA7L.js.map +1 -0
  16. package/dist/loop-M64D5YII.js +17 -0
  17. package/dist/main.js +177 -131
  18. package/dist/main.js.map +1 -1
  19. package/dist/{provider-EWMUPBLH.js → provider-3Z4B7RMF.js} +2 -2
  20. package/dist/{repl-SSCURTAH.js → repl-N2H7JDSH.js} +617 -327
  21. package/dist/repl-N2H7JDSH.js.map +1 -0
  22. package/dist/{tools-BH55BVIS.js → tools-5RKM7ZI3.js} +3 -3
  23. package/package.json +16 -25
  24. package/dist/chunk-2GAUAYCU.js.map +0 -1
  25. package/dist/chunk-F5RWZMQY.js.map +0 -1
  26. package/dist/chunk-LSYGHLRU.js.map +0 -1
  27. package/dist/chunk-MYHD3SZB.js.map +0 -1
  28. package/dist/chunk-Q5FJN4LN.js.map +0 -1
  29. package/dist/loop-BYXBNFFY.js +0 -17
  30. package/dist/repl-SSCURTAH.js.map +0 -1
  31. /package/dist/{loop-BYXBNFFY.js.map → loop-M64D5YII.js.map} +0 -0
  32. /package/dist/{provider-EWMUPBLH.js.map → provider-3Z4B7RMF.js.map} +0 -0
  33. /package/dist/{tools-BH55BVIS.js.map → tools-5RKM7ZI3.js.map} +0 -0
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ computeCostUsd,
3
4
  coreMessageText,
4
5
  pruneTreeReposExcept,
5
6
  removeTreeRepo
6
- } from "./chunk-MYHD3SZB.js";
7
+ } from "./chunk-CECMCDZG.js";
7
8
  import {
8
9
  SKIP_DIRS,
9
10
  createIgnorePredicate,
10
11
  downsampleImageIfNeeded,
11
12
  isSensitivePath,
12
13
  sessionsDir
13
- } from "./chunk-LSYGHLRU.js";
14
+ } from "./chunk-2ISBRSBA.js";
14
15
 
15
16
  // src/cli/cost.ts
16
17
  function formatDollars(cost) {
@@ -35,14 +36,6 @@ function formatTokensCompact(n) {
35
36
  const scaled = thousands ? n / 1e3 : n / 1e6;
36
37
  return `${scaled.toFixed(1).replace(/\.0$/, "")}${thousands ? "K" : "M"}`;
37
38
  }
38
- function computeCostUsd(usage, model) {
39
- if (model?.promptPrice === void 0 || model.completionPrice === void 0) {
40
- return null;
41
- }
42
- const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
43
- const promptUsd = (usage.promptTokens - cached) * model.promptPrice + cached * (model.cacheReadPrice ?? model.promptPrice) + (usage.cacheReadInputTokens ?? 0) * (model.cacheReadPrice ?? 0);
44
- return (promptUsd + usage.completionTokens * model.completionPrice) / 1e6;
45
- }
46
39
  function estimateCost(usage, modelName, model) {
47
40
  const cost = computeCostUsd(usage, model);
48
41
  if (cost === null) {
@@ -133,6 +126,58 @@ function toTerminalSafe(text) {
133
126
  if (!UNSAFE_CHARS.test(text)) return text;
134
127
  return text.replace(TABS, " ").replace(CONTROL_CHARS, "");
135
128
  }
129
+ var WIDE_RANGES = [
130
+ [4352, 4447],
131
+ // Hangul Jamo
132
+ [11904, 42191],
133
+ // CJK radicals … Yi syllables
134
+ [43360, 43388],
135
+ // Hangul Jamo Extended-B
136
+ [44032, 55203],
137
+ // Hangul syllables
138
+ [63744, 64255],
139
+ // CJK compatibility ideographs
140
+ [65040, 65049],
141
+ // vertical forms
142
+ [65072, 65106],
143
+ // CJK compatibility forms
144
+ [65108, 65126],
145
+ // small form variants
146
+ [65128, 65131],
147
+ [65280, 65376],
148
+ // fullwidth forms
149
+ [65504, 65510],
150
+ // fullwidth signs
151
+ [127744, 128591],
152
+ // emoji
153
+ [129280, 129535],
154
+ // supplemental emoji
155
+ [131072, 262141]
156
+ // CJK extension planes
157
+ ];
158
+ function isWideChar(ch) {
159
+ const cp = ch.codePointAt(0) ?? 0;
160
+ return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
161
+ }
162
+ function displayWidth(text) {
163
+ let width = 0;
164
+ for (const ch of text) width += isWideChar(ch) ? 2 : 1;
165
+ return width;
166
+ }
167
+ function truncateToWidth(text, maxWidth) {
168
+ if (maxWidth <= 0) return "";
169
+ if (displayWidth(text) <= maxWidth) return text;
170
+ const limit = Math.max(0, maxWidth - 1);
171
+ let width = 0;
172
+ let out = "";
173
+ for (const ch of text) {
174
+ const w = isWideChar(ch) ? 2 : 1;
175
+ if (width + w > limit) break;
176
+ out += ch;
177
+ width += w;
178
+ }
179
+ return `${out}\u2026`;
180
+ }
136
181
 
137
182
  // src/cli/markdown.ts
138
183
  var ESC = "\x1B[";
@@ -640,15 +685,153 @@ ${buf.toString("utf8")}
640
685
  }
641
686
 
642
687
  // src/session/checkpoints.ts
688
+ import { randomUUID as randomUUID3 } from "crypto";
689
+ import fs3 from "fs/promises";
690
+ import path4 from "path";
691
+
692
+ // src/session/store.ts
693
+ import { randomUUID as randomUUID2 } from "crypto";
694
+ import { existsSync } from "fs";
643
695
  import fs2 from "fs/promises";
644
696
  import path3 from "path";
697
+ import { StringDecoder } from "string_decoder";
645
698
 
646
- // src/session/store.ts
699
+ // src/session/lock.ts
647
700
  import { randomUUID } from "crypto";
648
- import { existsSync } from "fs";
649
701
  import fs from "fs/promises";
650
702
  import path2 from "path";
651
- import { StringDecoder } from "string_decoder";
703
+ var LOCK_FILENAME = ".lock";
704
+ var RECLAIM_FILENAME = ".lock.reclaim";
705
+ var MALFORMED_LOCK_GRACE_MS = 5e3;
706
+ var RECLAIM_STALE_MS = 3e4;
707
+ var SessionLockedError = class extends Error {
708
+ constructor(sessionId) {
709
+ super(`Session is already open in another process: ${sessionId}`);
710
+ this.sessionId = sessionId;
711
+ this.name = "SessionLockedError";
712
+ }
713
+ sessionId;
714
+ code = "SESSION_LOCKED";
715
+ };
716
+ function lockPath(sessionDir) {
717
+ return path2.join(sessionDir, LOCK_FILENAME);
718
+ }
719
+ function processIsAlive(pid) {
720
+ if (!Number.isInteger(pid) || pid <= 0) return false;
721
+ try {
722
+ process.kill(pid, 0);
723
+ return true;
724
+ } catch (error) {
725
+ return error.code === "EPERM";
726
+ }
727
+ }
728
+ async function readLockRecord(file) {
729
+ try {
730
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
731
+ if (typeof parsed.pid !== "number" || typeof parsed.token !== "string" || typeof parsed.createdAt !== "number") {
732
+ return null;
733
+ }
734
+ return { pid: parsed.pid, token: parsed.token, createdAt: parsed.createdAt };
735
+ } catch {
736
+ return null;
737
+ }
738
+ }
739
+ async function malformedLockIsStale(file) {
740
+ try {
741
+ const stat2 = await fs.stat(file);
742
+ return Date.now() - stat2.mtimeMs >= MALFORMED_LOCK_GRACE_MS;
743
+ } catch {
744
+ return false;
745
+ }
746
+ }
747
+ async function reclaimStaleLock(sessionDir, expected) {
748
+ const guard = path2.join(sessionDir, RECLAIM_FILENAME);
749
+ let guardHandle;
750
+ try {
751
+ guardHandle = await fs.open(guard, "wx", 384);
752
+ } catch (error) {
753
+ if (error.code !== "EEXIST") throw error;
754
+ try {
755
+ const stat2 = await fs.stat(guard);
756
+ if (Date.now() - stat2.mtimeMs < RECLAIM_STALE_MS) return false;
757
+ await fs.unlink(guard);
758
+ } catch {
759
+ return false;
760
+ }
761
+ return false;
762
+ }
763
+ const quarantine = `${lockPath(sessionDir)}.stale-${process.pid}-${randomUUID()}`;
764
+ try {
765
+ const file = lockPath(sessionDir);
766
+ const current = await readLockRecord(file);
767
+ if (expected) {
768
+ if (current?.token !== expected.token || processIsAlive(current.pid)) return false;
769
+ } else {
770
+ if (current) {
771
+ if (processIsAlive(current.pid)) return false;
772
+ } else if (!await malformedLockIsStale(file)) {
773
+ return false;
774
+ }
775
+ }
776
+ await fs.rename(file, quarantine);
777
+ await fs.unlink(quarantine).catch(() => {
778
+ });
779
+ return true;
780
+ } catch (error) {
781
+ if (error.code === "ENOENT") return true;
782
+ throw error;
783
+ } finally {
784
+ await guardHandle.close().catch(() => {
785
+ });
786
+ await fs.unlink(guard).catch(() => {
787
+ });
788
+ }
789
+ }
790
+ async function acquireSessionLock(sessionDir, sessionId) {
791
+ await fs.mkdir(sessionDir, { recursive: true, mode: 448 });
792
+ const file = lockPath(sessionDir);
793
+ const record = { pid: process.pid, token: randomUUID(), createdAt: Date.now() };
794
+ for (let attempt = 0; attempt < 20; attempt++) {
795
+ let handle;
796
+ try {
797
+ handle = await fs.open(file, "wx", 384);
798
+ } catch (error) {
799
+ if (error.code !== "EEXIST") throw error;
800
+ const existing = await readLockRecord(file);
801
+ if (existing ? processIsAlive(existing.pid) : !await malformedLockIsStale(file)) {
802
+ throw new SessionLockedError(sessionId);
803
+ }
804
+ if (!await reclaimStaleLock(sessionDir, existing)) {
805
+ const current = await readLockRecord(file);
806
+ if (current && processIsAlive(current.pid)) throw new SessionLockedError(sessionId);
807
+ await new Promise((resolve) => setTimeout(resolve, 10));
808
+ }
809
+ continue;
810
+ }
811
+ try {
812
+ await handle.writeFile(JSON.stringify(record), "utf8");
813
+ await handle.close();
814
+ return {
815
+ release: async () => {
816
+ if ((await readLockRecord(file))?.token === record.token) {
817
+ await fs.unlink(file).catch((error) => {
818
+ if (error.code !== "ENOENT") throw error;
819
+ });
820
+ }
821
+ }
822
+ };
823
+ } catch (error) {
824
+ await handle.close().catch(() => {
825
+ });
826
+ await fs.unlink(file).catch(() => {
827
+ });
828
+ throw error;
829
+ }
830
+ }
831
+ throw new SessionLockedError(sessionId);
832
+ }
833
+
834
+ // src/session/store.ts
652
835
  function generateId(now) {
653
836
  const pad = (n) => String(n).padStart(2, "0");
654
837
  const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
@@ -658,10 +841,10 @@ function generateId(now) {
658
841
  var ID_COLLISION_MAX_ATTEMPTS = 5;
659
842
  function generateUniqueId() {
660
843
  let id = generateId(/* @__PURE__ */ new Date());
661
- for (let attempt = 1; existsSync(path2.join(sessionsDir(), id)); attempt++) {
844
+ for (let attempt = 1; existsSync(path3.join(sessionsDir(), id)); attempt++) {
662
845
  if (attempt >= ID_COLLISION_MAX_ATTEMPTS) {
663
846
  const stamp = id.slice(0, id.lastIndexOf("-"));
664
- return `${stamp}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
847
+ return `${stamp}-${randomUUID2().replaceAll("-", "").slice(0, 12)}`;
665
848
  }
666
849
  id = generateId(/* @__PURE__ */ new Date());
667
850
  }
@@ -669,7 +852,7 @@ function generateUniqueId() {
669
852
  }
670
853
  function normalizeCwdForCompare(cwd, platform = process.platform) {
671
854
  if (platform !== "win32") return cwd;
672
- const resolved = path2.win32.resolve(cwd);
855
+ const resolved = path3.win32.resolve(cwd);
673
856
  const trimmed = resolved.replace(/[\\/]+$/, "");
674
857
  return (trimmed === "" ? resolved : trimmed).toLowerCase();
675
858
  }
@@ -738,33 +921,48 @@ var SessionStore = class _SessionStore {
738
921
  dir;
739
922
  pendingMeta = null;
740
923
  initialized = false;
924
+ initialization = null;
741
925
  cachedMeta = null;
742
- metaTmpSeq = 0;
743
- messagesTmpSeq = 0;
926
+ lock = null;
927
+ closing = false;
928
+ closed = false;
929
+ messageHistory = null;
930
+ degraded = false;
931
+ messageWriteQueue = Promise.resolve();
744
932
  metaWriteQueue = Promise.resolve();
745
933
  metaLastFlushAt = 0;
746
934
  metaFlushTimer = null;
747
935
  metaDirty = false;
748
936
  constructor(id) {
749
937
  this.id = id;
750
- this.dir = path2.join(sessionsDir(), id);
938
+ this.dir = path3.join(sessionsDir(), id);
751
939
  }
752
940
  metaPath() {
753
- return path2.join(this.dir, "meta.json");
941
+ return path3.join(this.dir, "meta.json");
754
942
  }
755
943
  messagesPath() {
756
- return path2.join(this.dir, "messages.jsonl");
944
+ return path3.join(this.dir, "messages.jsonl");
757
945
  }
758
946
  static async create(cwd, model) {
759
- const store = new _SessionStore(generateUniqueId());
760
- store.pendingMeta = { cwd, model, createdAt: Date.now() };
761
- return store;
947
+ for (let attempt = 0; attempt < ID_COLLISION_MAX_ATTEMPTS; attempt++) {
948
+ const store = new _SessionStore(generateUniqueId());
949
+ try {
950
+ store.lock = await acquireSessionLock(store.dir, store.id);
951
+ } catch (error) {
952
+ if (error instanceof SessionLockedError) continue;
953
+ throw error;
954
+ }
955
+ store.pendingMeta = { cwd, model, createdAt: Date.now() };
956
+ store.messageHistory = [];
957
+ return store;
958
+ }
959
+ throw new Error("Could not allocate an exclusive session id");
762
960
  }
763
961
  static async open(id) {
764
962
  const store = new _SessionStore(id);
765
963
  let raw;
766
964
  try {
767
- raw = await fs.readFile(store.metaPath(), "utf8");
965
+ raw = await fs2.readFile(store.metaPath(), "utf8");
768
966
  } catch {
769
967
  return null;
770
968
  }
@@ -774,32 +972,44 @@ var SessionStore = class _SessionStore {
774
972
  } catch {
775
973
  debugWarn(`session ${id}: corrupt meta.json, using defaults`);
776
974
  }
777
- await store.sweepTmpFiles();
778
- store.initialized = true;
779
- return store;
975
+ store.lock = await acquireSessionLock(store.dir, id);
976
+ try {
977
+ await store.sweepTmpFiles();
978
+ store.initialized = true;
979
+ return store;
980
+ } catch (error) {
981
+ await store.lock.release();
982
+ throw error;
983
+ }
780
984
  }
781
985
  static async list(cwd) {
782
986
  let entries;
783
987
  try {
784
- entries = await fs.readdir(sessionsDir());
988
+ entries = await fs2.readdir(sessionsDir());
785
989
  } catch {
786
990
  return [];
787
991
  }
788
992
  const wanted = cwd === void 0 ? void 0 : normalizeCwdForCompare(cwd);
789
- const metas = await Promise.all(
790
- entries.map(async (entry) => {
993
+ const metas = [];
994
+ let next = 0;
995
+ const worker = async () => {
996
+ while (next < entries.length) {
997
+ const index = next;
998
+ next += 1;
999
+ const entry = entries[index];
1000
+ if (!entry) continue;
791
1001
  try {
792
- const raw = await fs.readFile(path2.join(sessionsDir(), entry, "meta.json"), "utf8");
1002
+ const raw = await fs2.readFile(path3.join(sessionsDir(), entry, "meta.json"), "utf8");
793
1003
  const meta = JSON.parse(raw);
794
1004
  if (meta.id === entry && (wanted === void 0 || normalizeCwdForCompare(meta.cwd) === wanted)) {
795
- return meta;
1005
+ metas[index] = meta;
796
1006
  }
797
1007
  } catch {
798
1008
  }
799
- return null;
800
- })
801
- );
802
- return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt - a.updatedAt);
1009
+ }
1010
+ };
1011
+ await Promise.all(Array.from({ length: Math.min(8, entries.length) }, () => worker()));
1012
+ return metas.filter((meta) => meta !== null && meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
803
1013
  }
804
1014
  // Meta writes are serialized per instance and land atomically (tmp file +
805
1015
  // rename) over a cached in-memory meta: the fire-and-forget title/usage
@@ -812,15 +1022,15 @@ var SessionStore = class _SessionStore {
812
1022
  // agent turn over a stale updatedAt would be the wrong trade.
813
1023
  writeMeta(meta) {
814
1024
  if (isFallbackMeta(meta)) return Promise.resolve();
815
- const tmp = `${this.metaPath()}.tmp-${process.pid}-${this.metaTmpSeq++}`;
1025
+ const tmp = `${this.metaPath()}.tmp-${process.pid}-${randomUUID2()}`;
816
1026
  const run = this.metaWriteQueue.then(async () => {
817
1027
  try {
818
1028
  await withTransientFsRetry(async () => {
819
- await fs.writeFile(tmp, JSON.stringify(meta, null, 2));
820
- await fs.rename(tmp, this.metaPath());
1029
+ await fs2.writeFile(tmp, JSON.stringify(meta, null, 2));
1030
+ await fs2.rename(tmp, this.metaPath());
821
1031
  });
822
1032
  } catch (error) {
823
- await fs.unlink(tmp).catch(() => {
1033
+ await fs2.unlink(tmp).catch(() => {
824
1034
  });
825
1035
  debugWarn(
826
1036
  `session ${this.id}: meta write failed (${error.code ?? String(error)}), skipping`
@@ -831,10 +1041,6 @@ var SessionStore = class _SessionStore {
831
1041
  });
832
1042
  return run;
833
1043
  }
834
- // Debounced flush for append-driven updatedAt bumps. The trailing timer is
835
- // unref'd so a pending flush never keeps the process alive; close() flushes
836
- // synchronously on a clean exit, and a hard exit simply drops the trailing
837
- // bump — meta is bookkeeping.
838
1044
  markMetaDirty() {
839
1045
  this.metaDirty = true;
840
1046
  if (this.metaFlushTimer) return;
@@ -853,9 +1059,6 @@ var SessionStore = class _SessionStore {
853
1059
  if (!this.metaDirty) return;
854
1060
  await this.flushMetaNow();
855
1061
  }
856
- // Immediate flush for substantive meta changes; cancels a pending debounced
857
- // flush, which the write makes redundant either way (it serializes the live
858
- // cached meta).
859
1062
  async flushMetaNow() {
860
1063
  if (this.metaFlushTimer) {
861
1064
  clearTimeout(this.metaFlushTimer);
@@ -865,96 +1068,172 @@ var SessionStore = class _SessionStore {
865
1068
  this.metaLastFlushAt = Date.now();
866
1069
  if (this.cachedMeta) await this.writeMeta(this.cachedMeta);
867
1070
  }
868
- // Flushes a pending debounced meta write and waits for in-flight writes to
869
- // settle; call when the store is dropped so a clean exit keeps the trailing
870
- // updatedAt bump.
871
1071
  async close() {
872
- await this.flushMetaIfDirty();
873
- await this.metaWriteQueue;
1072
+ if (this.closed || this.closing) return;
1073
+ this.closing = true;
1074
+ try {
1075
+ await this.messageWriteQueue;
1076
+ await this.flushMetaIfDirty();
1077
+ await this.metaWriteQueue;
1078
+ } finally {
1079
+ if (this.metaFlushTimer) clearTimeout(this.metaFlushTimer);
1080
+ this.metaFlushTimer = null;
1081
+ this.closed = true;
1082
+ this.closing = false;
1083
+ const lock = this.lock;
1084
+ this.lock = null;
1085
+ await lock?.release();
1086
+ }
1087
+ }
1088
+ assertCanWrite() {
1089
+ if (this.closed || this.closing || !this.lock) {
1090
+ throw new Error(`Session store is closed: ${this.id}`);
1091
+ }
874
1092
  }
875
1093
  async ensureInitialized() {
876
1094
  if (this.initialized) return;
877
- const pending = this.pendingMeta;
878
- if (!pending) {
1095
+ if (this.initialization) return this.initialization;
1096
+ this.initialization = (async () => {
1097
+ const pending = this.pendingMeta;
1098
+ if (!pending) {
1099
+ this.initialized = true;
1100
+ return;
1101
+ }
1102
+ await this.sweepTmpFiles();
1103
+ const meta = {
1104
+ id: this.id,
1105
+ title: "",
1106
+ model: pending.model,
1107
+ cwd: pending.cwd,
1108
+ createdAt: pending.createdAt,
1109
+ updatedAt: pending.createdAt
1110
+ };
1111
+ await this.writeMeta(meta);
1112
+ this.cachedMeta = meta;
1113
+ this.metaLastFlushAt = Date.now();
879
1114
  this.initialized = true;
880
- return;
1115
+ })();
1116
+ try {
1117
+ await this.initialization;
1118
+ } catch (error) {
1119
+ this.initialization = null;
1120
+ throw error;
881
1121
  }
882
- await fs.mkdir(this.dir, { recursive: true, mode: 448 });
883
- await this.sweepTmpFiles();
884
- const meta = {
885
- id: this.id,
886
- title: "",
887
- model: pending.model,
888
- cwd: pending.cwd,
889
- createdAt: pending.createdAt,
890
- updatedAt: pending.createdAt
891
- };
892
- await this.writeMeta(meta);
893
- this.cachedMeta = meta;
894
- this.metaLastFlushAt = Date.now();
895
- this.initialized = true;
896
1122
  }
897
- // Removes tmp orphans left behind by crashed atomic writes (both meta.json
898
- // and messages.jsonl write tmp file + rename); best-effort, the directory
899
- // may not even exist yet.
900
1123
  async sweepTmpFiles() {
901
1124
  let entries;
902
1125
  try {
903
- entries = await fs.readdir(this.dir);
1126
+ entries = await fs2.readdir(this.dir);
904
1127
  } catch {
905
1128
  return;
906
1129
  }
907
1130
  await Promise.all(
908
- entries.filter(
909
- (entry) => entry.startsWith("meta.json.tmp-") || entry.startsWith("messages.jsonl.tmp-")
910
- ).map((entry) => fs.unlink(path2.join(this.dir, entry)).catch(() => {
911
- }))
1131
+ entries.map(async (entry) => {
1132
+ const match = /^(?:meta\.json|messages\.jsonl)\.tmp-(\d+)-/.exec(entry);
1133
+ if (!match) return;
1134
+ const pid = Number(match[1]);
1135
+ try {
1136
+ process.kill(pid, 0);
1137
+ return;
1138
+ } catch (error) {
1139
+ if (error.code === "EPERM") return;
1140
+ }
1141
+ await fs2.unlink(path3.join(this.dir, entry)).catch(() => {
1142
+ });
1143
+ })
912
1144
  );
913
1145
  }
914
- async append(message, meta) {
915
- await this.ensureInitialized();
916
- try {
917
- await withTransientFsRetry(
918
- () => fs.appendFile(this.messagesPath(), `${serializeMessage({ message, meta })}
919
- `)
920
- );
921
- } catch (error) {
922
- warn(
923
- `session ${this.id}: message append failed (${error.code ?? String(error)}), message kept in memory only`
924
- );
925
- }
926
- const sessionMeta = await this.meta();
927
- sessionMeta.updatedAt = Date.now();
928
- this.markMetaDirty();
1146
+ enqueueMessageWrite(operation) {
1147
+ const run = this.messageWriteQueue.then(operation);
1148
+ this.messageWriteQueue = run.then(
1149
+ () => {
1150
+ },
1151
+ () => {
1152
+ }
1153
+ );
1154
+ return run;
929
1155
  }
930
- async replaceMessages(messages) {
1156
+ get persistenceDegraded() {
1157
+ return this.degraded;
1158
+ }
1159
+ async ensureMessageHistory() {
1160
+ if (!this.messageHistory) this.messageHistory = await this.readStarMessages(true);
1161
+ return this.messageHistory;
1162
+ }
1163
+ async rewriteMessages(messages) {
931
1164
  await this.ensureInitialized();
1165
+ this.messageHistory = [...messages];
932
1166
  const content = messages.map(serializeMessage).join("\n");
933
- const tmp = `${this.messagesPath()}.tmp-${process.pid}-${this.messagesTmpSeq++}`;
1167
+ const tmp = `${this.messagesPath()}.tmp-${process.pid}-${randomUUID2()}`;
934
1168
  try {
935
1169
  await withTransientFsRetry(async () => {
936
- await fs.writeFile(tmp, content ? `${content}
1170
+ await fs2.writeFile(tmp, content ? `${content}
937
1171
  ` : "");
938
- await fs.rename(tmp, this.messagesPath());
1172
+ await fs2.rename(tmp, this.messagesPath());
939
1173
  });
940
1174
  } catch (error) {
941
- await fs.unlink(tmp).catch(() => {
1175
+ await fs2.unlink(tmp).catch(() => {
942
1176
  });
1177
+ this.degraded = true;
943
1178
  warn(
944
1179
  `session ${this.id}: history rewrite failed (${error.code ?? String(error)}), kept the previous messages.jsonl`
945
1180
  );
1181
+ return false;
946
1182
  }
1183
+ this.degraded = false;
947
1184
  const meta = await this.meta();
948
1185
  meta.updatedAt = Date.now();
949
1186
  await this.flushMetaNow();
1187
+ return true;
1188
+ }
1189
+ async append(message, meta) {
1190
+ this.assertCanWrite();
1191
+ return this.enqueueMessageWrite(async () => {
1192
+ await this.ensureInitialized();
1193
+ const history = await this.ensureMessageHistory();
1194
+ const star = { message, meta };
1195
+ if (this.degraded) return this.rewriteMessages([...history, star]);
1196
+ try {
1197
+ await withTransientFsRetry(
1198
+ () => fs2.appendFile(this.messagesPath(), `${serializeMessage(star)}
1199
+ `)
1200
+ );
1201
+ } catch (error) {
1202
+ this.messageHistory = [...history, star];
1203
+ this.degraded = true;
1204
+ warn(
1205
+ `session ${this.id}: message append failed (${error.code ?? String(error)}), full history rewrite required`
1206
+ );
1207
+ return false;
1208
+ }
1209
+ history.push(star);
1210
+ const sessionMeta = await this.meta();
1211
+ sessionMeta.updatedAt = Date.now();
1212
+ this.markMetaDirty();
1213
+ return true;
1214
+ });
1215
+ }
1216
+ async replaceMessages(messages) {
1217
+ this.assertCanWrite();
1218
+ return this.enqueueMessageWrite(() => this.rewriteMessages(messages));
1219
+ }
1220
+ async recoverMessages() {
1221
+ this.assertCanWrite();
1222
+ return this.enqueueMessageWrite(async () => {
1223
+ if (!this.degraded) return true;
1224
+ return this.rewriteMessages(await this.ensureMessageHistory());
1225
+ });
950
1226
  }
951
1227
  async messages() {
952
1228
  return (await this.starMessages()).map((star) => star.message);
953
1229
  }
954
1230
  async starMessages() {
1231
+ return this.readStarMessages(false);
1232
+ }
1233
+ async readStarMessages(strict) {
955
1234
  let handle = null;
956
1235
  try {
957
- handle = await fs.open(this.messagesPath(), "r");
1236
+ handle = await fs2.open(this.messagesPath(), "r");
958
1237
  const messages = [];
959
1238
  const decoder = new StringDecoder("utf8");
960
1239
  const buffer = Buffer.alloc(MESSAGES_READ_CHUNK_BYTES);
@@ -979,7 +1258,8 @@ var SessionStore = class _SessionStore {
979
1258
  }
980
1259
  pushLine(tail + decoder.end());
981
1260
  return messages;
982
- } catch {
1261
+ } catch (error) {
1262
+ if (strict && error.code !== "ENOENT") throw error;
983
1263
  return [];
984
1264
  } finally {
985
1265
  await handle?.close().catch(() => {
@@ -1003,7 +1283,7 @@ var SessionStore = class _SessionStore {
1003
1283
  await this.ensureInitialized();
1004
1284
  if (this.cachedMeta) return this.cachedMeta;
1005
1285
  try {
1006
- const raw = await fs.readFile(this.metaPath(), "utf8");
1286
+ const raw = await fs2.readFile(this.metaPath(), "utf8");
1007
1287
  this.cachedMeta = JSON.parse(raw);
1008
1288
  return this.cachedMeta;
1009
1289
  } catch {
@@ -1012,70 +1292,102 @@ var SessionStore = class _SessionStore {
1012
1292
  }
1013
1293
  }
1014
1294
  async setTitle(title) {
1015
- await this.ensureInitialized();
1016
- const meta = await this.meta();
1017
- meta.title = title;
1018
- meta.updatedAt = Date.now();
1019
- await this.flushMetaNow();
1295
+ this.assertCanWrite();
1296
+ return this.enqueueMessageWrite(async () => {
1297
+ await this.ensureInitialized();
1298
+ const meta = await this.meta();
1299
+ meta.title = title;
1300
+ meta.updatedAt = Date.now();
1301
+ await this.flushMetaNow();
1302
+ });
1020
1303
  }
1021
1304
  // /model swaps the live loop mid-session; keep the recorded model in sync
1022
1305
  // or the session list and /usage attribute the whole session to whichever
1023
1306
  // model happened to be active at creation.
1024
1307
  async setModel(model) {
1025
- await this.ensureInitialized();
1026
- const meta = await this.meta();
1027
- meta.model = model;
1028
- meta.updatedAt = Date.now();
1029
- await this.flushMetaNow();
1308
+ this.assertCanWrite();
1309
+ return this.enqueueMessageWrite(async () => {
1310
+ await this.ensureInitialized();
1311
+ const meta = await this.meta();
1312
+ meta.model = model;
1313
+ meta.updatedAt = Date.now();
1314
+ await this.flushMetaNow();
1315
+ });
1030
1316
  }
1031
1317
  async appendCheckpoint(record, content) {
1032
- await this.ensureInitialized();
1033
- await appendCheckpointRecord(this.dir, record, content);
1318
+ this.assertCanWrite();
1319
+ return this.enqueueMessageWrite(async () => {
1320
+ await this.ensureInitialized();
1321
+ await appendCheckpointRecord(this.dir, record, content);
1322
+ });
1034
1323
  }
1035
1324
  async listCheckpoints() {
1036
1325
  return listCheckpointRecords(this.dir);
1037
1326
  }
1038
1327
  async removeCheckpoints(ids) {
1039
- await removeCheckpointRecords(this.dir, ids);
1328
+ this.assertCanWrite();
1329
+ return this.enqueueMessageWrite(() => removeCheckpointRecords(this.dir, ids));
1040
1330
  }
1041
- async addUsage(delta) {
1042
- await this.ensureInitialized();
1043
- const meta = await this.meta();
1044
- const usage = meta.usage ?? {
1045
- requests: 0,
1046
- promptTokens: 0,
1047
- completionTokens: 0,
1048
- totalTokens: 0
1049
- };
1050
- usage.requests += 1;
1051
- usage.promptTokens += delta.promptTokens;
1052
- usage.completionTokens += delta.completionTokens;
1053
- usage.totalTokens += delta.totalTokens;
1054
- if (delta.cachedPromptTokens) {
1055
- usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + delta.cachedPromptTokens;
1056
- }
1057
- if (delta.cacheReadInputTokens) {
1058
- usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + delta.cacheReadInputTokens;
1059
- }
1060
- meta.usage = usage;
1061
- const day = dayKey(/* @__PURE__ */ new Date());
1062
- const byDay = meta.usageByDay ?? {};
1063
- const bucket = byDay[day] ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1064
- bucket.promptTokens += delta.promptTokens;
1065
- bucket.completionTokens += delta.completionTokens;
1066
- bucket.totalTokens += delta.totalTokens;
1067
- byDay[day] = bucket;
1068
- meta.usageByDay = byDay;
1069
- await this.flushMetaNow();
1331
+ async addUsage(delta, attribution) {
1332
+ this.assertCanWrite();
1333
+ return this.enqueueMessageWrite(async () => {
1334
+ await this.ensureInitialized();
1335
+ const meta = await this.meta();
1336
+ const apply = (usage2) => {
1337
+ usage2.requests += 1;
1338
+ usage2.promptTokens += delta.promptTokens;
1339
+ usage2.completionTokens += delta.completionTokens;
1340
+ usage2.totalTokens += delta.totalTokens;
1341
+ if (delta.cachedPromptTokens) {
1342
+ usage2.cachedPromptTokens = (usage2.cachedPromptTokens ?? 0) + delta.cachedPromptTokens;
1343
+ }
1344
+ if (delta.cacheReadInputTokens) {
1345
+ usage2.cacheReadInputTokens = (usage2.cacheReadInputTokens ?? 0) + delta.cacheReadInputTokens;
1346
+ }
1347
+ if (attribution) {
1348
+ if (attribution.costUsd === null) usage2.costUnknown = true;
1349
+ else usage2.costUsd = (usage2.costUsd ?? 0) + attribution.costUsd;
1350
+ }
1351
+ };
1352
+ const usage = meta.usage ?? {
1353
+ requests: 0,
1354
+ promptTokens: 0,
1355
+ completionTokens: 0,
1356
+ totalTokens: 0
1357
+ };
1358
+ apply(usage);
1359
+ meta.usage = usage;
1360
+ if (attribution) {
1361
+ const byModel = meta.usageByModel ?? {};
1362
+ const modelUsage = byModel[attribution.modelName] ?? {
1363
+ requests: 0,
1364
+ promptTokens: 0,
1365
+ completionTokens: 0,
1366
+ totalTokens: 0
1367
+ };
1368
+ apply(modelUsage);
1369
+ byModel[attribution.modelName] = modelUsage;
1370
+ meta.usageByModel = byModel;
1371
+ }
1372
+ const day = dayKey(/* @__PURE__ */ new Date());
1373
+ const byDay = meta.usageByDay ?? {};
1374
+ const bucket = byDay[day] ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1375
+ bucket.promptTokens += delta.promptTokens;
1376
+ bucket.completionTokens += delta.completionTokens;
1377
+ bucket.totalTokens += delta.totalTokens;
1378
+ byDay[day] = bucket;
1379
+ meta.usageByDay = byDay;
1380
+ await this.flushMetaNow();
1381
+ });
1070
1382
  }
1071
1383
  };
1072
1384
 
1073
1385
  // src/session/checkpoints.ts
1074
1386
  function checkpointsDir(sessionDir) {
1075
- return path3.join(sessionDir, "checkpoints");
1387
+ return path4.join(sessionDir, "checkpoints");
1076
1388
  }
1077
1389
  function indexPath(sessionDir) {
1078
- return path3.join(checkpointsDir(sessionDir), "index.json");
1390
+ return path4.join(checkpointsDir(sessionDir), "index.json");
1079
1391
  }
1080
1392
  var indexWriteQueues = /* @__PURE__ */ new Map();
1081
1393
  function enqueueIndexWrite(sessionDir, op) {
@@ -1093,28 +1405,27 @@ function enqueueIndexWrite(sessionDir, op) {
1093
1405
  });
1094
1406
  return run;
1095
1407
  }
1096
- var indexTmpSeq = 0;
1097
1408
  async function writeIndex(sessionDir, records) {
1098
1409
  const target = indexPath(sessionDir);
1099
- const tmp = `${target}.tmp-${process.pid}-${indexTmpSeq++}`;
1410
+ const tmp = `${target}.tmp-${process.pid}-${randomUUID3()}`;
1100
1411
  try {
1101
1412
  await withTransientFsRetry(async () => {
1102
- await fs2.writeFile(tmp, JSON.stringify(records, null, 2));
1103
- await fs2.rename(tmp, target);
1413
+ await fs3.writeFile(tmp, JSON.stringify(records, null, 2));
1414
+ await fs3.rename(tmp, target);
1104
1415
  });
1105
1416
  } catch (error) {
1106
- await fs2.unlink(tmp).catch(() => {
1417
+ await fs3.unlink(tmp).catch(() => {
1107
1418
  });
1108
1419
  throw error;
1109
1420
  }
1110
1421
  }
1111
1422
  function checkpointContentPath(sessionDir, id) {
1112
- return path3.join(checkpointsDir(sessionDir), `${id}.snapshot`);
1423
+ return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
1113
1424
  }
1114
1425
  async function listCheckpointRecords(sessionDir) {
1115
1426
  let raw;
1116
1427
  try {
1117
- raw = await fs2.readFile(indexPath(sessionDir), "utf8");
1428
+ raw = await fs3.readFile(indexPath(sessionDir), "utf8");
1118
1429
  } catch {
1119
1430
  return [];
1120
1431
  }
@@ -1123,7 +1434,7 @@ async function listCheckpointRecords(sessionDir) {
1123
1434
  return Array.isArray(parsed) ? parsed : [];
1124
1435
  } catch {
1125
1436
  process.stderr.write(
1126
- `[star-cli] checkpoints index unreadable for session ${path3.basename(sessionDir)}, ignoring it
1437
+ `[star-cli] checkpoints index unreadable for session ${path4.basename(sessionDir)}, ignoring it
1127
1438
  `
1128
1439
  );
1129
1440
  return [];
@@ -1131,9 +1442,20 @@ async function listCheckpointRecords(sessionDir) {
1131
1442
  }
1132
1443
  async function appendCheckpointRecord(sessionDir, record, content) {
1133
1444
  await enqueueIndexWrite(sessionDir, async () => {
1134
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
1445
+ await fs3.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
1135
1446
  if (record.existed && content !== null) {
1136
- await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
1447
+ const target = checkpointContentPath(sessionDir, record.id);
1448
+ const tmp = `${target}.tmp-${process.pid}-${randomUUID3()}`;
1449
+ try {
1450
+ await withTransientFsRetry(async () => {
1451
+ await fs3.writeFile(tmp, content, "utf8");
1452
+ await fs3.rename(tmp, target);
1453
+ });
1454
+ } catch (error) {
1455
+ await fs3.unlink(tmp).catch(() => {
1456
+ });
1457
+ throw error;
1458
+ }
1137
1459
  }
1138
1460
  const records = await listCheckpointRecords(sessionDir);
1139
1461
  records.push(record);
@@ -1147,10 +1469,10 @@ async function removeCheckpointRecords(sessionDir, ids) {
1147
1469
  const records = await listCheckpointRecords(sessionDir);
1148
1470
  const kept = records.filter((record) => !drop.has(record.id));
1149
1471
  if (kept.length === records.length) return;
1150
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
1472
+ await fs3.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
1151
1473
  await writeIndex(sessionDir, kept);
1152
1474
  for (const id of ids) {
1153
- await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
1475
+ await fs3.rm(checkpointContentPath(sessionDir, id), { force: true });
1154
1476
  }
1155
1477
  });
1156
1478
  }
@@ -1164,13 +1486,13 @@ async function loadSessionSnapshots(sessionDir) {
1164
1486
  }
1165
1487
 
1166
1488
  // src/session/list.ts
1167
- import fs3 from "fs/promises";
1168
- import path4 from "path";
1489
+ import fs4 from "fs/promises";
1490
+ import path5 from "path";
1169
1491
  var COUNT_CHUNK_BYTES = 64 * 1024;
1170
1492
  async function countMessages(id) {
1171
1493
  let handle = null;
1172
1494
  try {
1173
- handle = await fs3.open(path4.join(sessionsDir(), id, "messages.jsonl"), "r");
1495
+ handle = await fs4.open(path5.join(sessionsDir(), id, "messages.jsonl"), "r");
1174
1496
  const buffer = Buffer.alloc(COUNT_CHUNK_BYTES);
1175
1497
  let count = 0;
1176
1498
  let lineHasContent = false;
@@ -1221,7 +1543,7 @@ async function findLatestSession(cwd) {
1221
1543
  async function sessionPreview(id, maxBytes = 4096) {
1222
1544
  let handle = null;
1223
1545
  try {
1224
- handle = await fs3.open(path4.join(sessionsDir(), id, "messages.jsonl"), "r");
1546
+ handle = await fs4.open(path5.join(sessionsDir(), id, "messages.jsonl"), "r");
1225
1547
  const buffer = Buffer.alloc(maxBytes);
1226
1548
  const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1227
1549
  const head = buffer.toString("utf8", 0, bytesRead);
@@ -1295,11 +1617,22 @@ When you report completion, state the evidence: which checks you ran and that th
1295
1617
  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.`;
1296
1618
 
1297
1619
  // src/session/clear.ts
1298
- import fs4 from "fs/promises";
1299
- import path5 from "path";
1620
+ import fs5 from "fs/promises";
1621
+ import path6 from "path";
1622
+ async function removeIfUnlocked(id) {
1623
+ const dir = path6.join(sessionsDir(), id);
1624
+ const lock = await acquireSessionLock(dir, id).catch((error) => {
1625
+ if (error instanceof SessionLockedError) return null;
1626
+ throw error;
1627
+ });
1628
+ if (!lock) return false;
1629
+ await lock.release();
1630
+ await fs5.rm(dir, { recursive: true, force: true });
1631
+ return true;
1632
+ }
1300
1633
  async function deleteSession(id) {
1301
1634
  const meta = (await SessionStore.list()).find((m) => m.id === id);
1302
- await fs4.rm(path5.join(sessionsDir(), id), { recursive: true, force: true });
1635
+ if (!await removeIfUnlocked(id)) return;
1303
1636
  if (meta && (await SessionStore.list(meta.cwd)).length === 0) {
1304
1637
  await removeTreeRepo(meta.cwd);
1305
1638
  }
@@ -1310,8 +1643,7 @@ async function clearSessions(cwd, excludeId) {
1310
1643
  let removed2 = 0;
1311
1644
  for (const meta of metas) {
1312
1645
  if (meta.id === excludeId) continue;
1313
- await fs4.rm(path5.join(sessionsDir(), meta.id), { recursive: true, force: true });
1314
- removed2 += 1;
1646
+ if (await removeIfUnlocked(meta.id)) removed2 += 1;
1315
1647
  }
1316
1648
  if ((await SessionStore.list(cwd)).length === 0) {
1317
1649
  await removeTreeRepo(cwd);
@@ -1320,15 +1652,14 @@ async function clearSessions(cwd, excludeId) {
1320
1652
  }
1321
1653
  let entries;
1322
1654
  try {
1323
- entries = await fs4.readdir(sessionsDir());
1655
+ entries = await fs5.readdir(sessionsDir());
1324
1656
  } catch {
1325
1657
  return 0;
1326
1658
  }
1327
1659
  let removed = 0;
1328
1660
  for (const entry of entries) {
1329
1661
  if (entry === excludeId) continue;
1330
- await fs4.rm(path5.join(sessionsDir(), entry), { recursive: true, force: true });
1331
- removed += 1;
1662
+ if (await removeIfUnlocked(entry)) removed += 1;
1332
1663
  }
1333
1664
  const survivors = await SessionStore.list();
1334
1665
  await pruneTreeReposExcept(survivors.map((meta) => meta.cwd));
@@ -1336,13 +1667,13 @@ async function clearSessions(cwd, excludeId) {
1336
1667
  }
1337
1668
 
1338
1669
  // src/version.ts
1339
- import fs5 from "fs";
1340
- import path6 from "path";
1670
+ import fs6 from "fs";
1671
+ import path7 from "path";
1341
1672
  import { fileURLToPath } from "url";
1342
1673
  function readPackageVersion() {
1343
1674
  try {
1344
- const pkgPath = path6.join(path6.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1345
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf8"));
1675
+ const pkgPath = path7.join(path7.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1676
+ const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf8"));
1346
1677
  if (typeof pkg === "object" && pkg !== null) {
1347
1678
  const version = pkg.version;
1348
1679
  if (typeof version === "string") return version;
@@ -1357,7 +1688,6 @@ export {
1357
1688
  formatDollars,
1358
1689
  formatTokens,
1359
1690
  formatTokensCompact,
1360
- computeCostUsd,
1361
1691
  estimateCost,
1362
1692
  resolveIconMode,
1363
1693
  toolIcon,
@@ -1365,6 +1695,8 @@ export {
1365
1695
  folderIcon,
1366
1696
  renderMarkdown,
1367
1697
  toTerminalSafe,
1698
+ displayWidth,
1699
+ truncateToWidth,
1368
1700
  summarizeArgs,
1369
1701
  previewLines,
1370
1702
  formatByteCount,
@@ -1390,4 +1722,4 @@ export {
1390
1722
  clearSessions,
1391
1723
  VERSION
1392
1724
  };
1393
- //# sourceMappingURL=chunk-2GAUAYCU.js.map
1725
+ //# sourceMappingURL=chunk-NRLGGA7L.js.map