@inetafrica/open-claudia 3.5.19 → 3.5.23

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.
@@ -659,6 +659,340 @@ function hookFailure(toolName, reason) {
659
659
  };
660
660
  }
661
661
 
662
+ // ── Browser app: the bot pauses while a person drives (bot apps v1, C7) ────
663
+ // While someone drives the bot's browser from Kazee Chat, the driver lock file
664
+ // (core/browser-lock.js) is live, and the bot's own agent-browser commands
665
+ // would fight their clicks. So an agent-browser call at a command position is
666
+ // refused, except the read-only ones: get, snapshot, screenshot, session list.
667
+ // A command position is the start of a command: the start of the line, or
668
+ // after ; & | && || ( $( a backtick or a newline, behind env assignments
669
+ // (FOO=1), shell words (if, then, do, !, {) and wrappers (npx, sudo, env,
670
+ // timeout, nice, xargs...). What a shell runs from a string is read the same
671
+ // way: bash/sh -c '...', eval, a heredoc or here-string fed to a shell, and
672
+ // $(...) or backticks, inside double quotes too. Any other string is data, so
673
+ // `echo "agent-browser click"`, `grep agent-browser`, a commit message or a
674
+ // heredoc written to a file never match. An expired lock never blocks, and any
675
+ // error here allows: this is a pause for a person's sake, not a security gate.
676
+ const AGENT_BROWSER_READ_ONLY = new Set(["get", "snapshot", "screenshot"]);
677
+ const AGENT_BROWSER_VALUE_FLAGS = new Set([
678
+ "--session", "--restore-save", "--restore-check-url", "--restore-check-text", "--restore-check-fn",
679
+ "--namespace", "--session-name", "--profile", "--state", "--headers", "--executable-path", "--extension",
680
+ "--init-script", "--enable", "--args", "--user-agent", "--proxy", "--proxy-bypass", "--ca-cert",
681
+ "--hide-scrollbars", "-p", "--provider", "--device", "--threshold", "--screenshot-dir", "--screenshot-quality",
682
+ "--screenshot-format", "--cdp", "--color-scheme", "--download-path", "--max-output", "--allowed-domains",
683
+ "--action-policy", "--confirm-actions", "--engine", "--input-mode", "--idle-timeout", "--model", "--config",
684
+ "--port", "--allowed-origins",
685
+ ]);
686
+ // agent-browser, agent-browser.js, agent-browser@latest, agent-browser-linux-x64...
687
+ const AGENT_BROWSER_BIN = /^agent-browser(?:$|[.@-])/;
688
+ const SHELL_WORDS = new Set(["!", "{", "if", "then", "else", "elif", "do", "while", "until"]);
689
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "fish"]);
690
+ // Wrappers that run the command after them, with their options that take a value.
691
+ const COMMAND_WRAPPERS = new Map([
692
+ ["sudo", ["-u", "-g", "-C", "-h", "-p", "-r", "-t", "-U", "-D", "-R", "-T", "--user", "--group", "--host", "--prompt", "--role", "--type", "--other-user", "--chdir", "--chroot", "--command-timeout", "--close-from"]],
693
+ ["doas", ["-u", "-C"]],
694
+ ["env", ["-u", "-C", "-S", "--unset", "--chdir", "--split-string"]],
695
+ ["command", []], ["builtin", []], ["exec", ["-a"]], ["nohup", []], ["setsid", []],
696
+ ["time", ["-f", "-o", "--format", "--output"]],
697
+ ["nice", ["-n", "--adjustment"]],
698
+ ["timeout", ["-s", "-k", "--signal", "--kill-after"]],
699
+ ["stdbuf", ["-i", "-o", "-e", "--input", "--output", "--error"]],
700
+ ["xargs", ["-I", "-n", "-L", "-P", "-d", "-E", "-s", "-a", "--max-args", "--max-lines", "--max-procs", "--delimiter", "--eof", "--max-chars", "--arg-file"]],
701
+ ["npx", ["-p", "--package", "-c", "--call"]],
702
+ ["pnpx", ["-p", "--package"]],
703
+ ["bunx", ["-p", "--package"]],
704
+ ]);
705
+ // Wrapper options whose value is itself a command line: npx -c, env -S.
706
+ const WRAPPER_SCRIPT_OPTIONS = new Set(["-c", "--call", "-S", "--split-string"]);
707
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
708
+
709
+ const baseName = (word) => String(word).split("/").pop();
710
+
711
+ // Where $( ... ) that opens at `start` (just after "$(") closes: the index of
712
+ // its ")", or the end of the text. Nested parentheses and quotes are skipped.
713
+ function substitutionEnd(s, start) {
714
+ let depth = 1;
715
+ let i = start;
716
+ while (i < s.length) {
717
+ const c = s[i];
718
+ if (c === "\\") { i += 2; continue; }
719
+ if (c === "'") { const end = s.indexOf("'", i + 1); i = end < 0 ? s.length : end + 1; continue; }
720
+ if (c === '"') {
721
+ let j = i + 1;
722
+ while (j < s.length && s[j] !== '"') j += s[j] === "\\" ? 2 : 1;
723
+ i = j + 1;
724
+ continue;
725
+ }
726
+ if (c === "(") depth += 1;
727
+ else if (c === ")") { depth -= 1; if (depth === 0) return i; }
728
+ i += 1;
729
+ }
730
+ return s.length;
731
+ }
732
+
733
+ // The commands of a shell command line, the way a shell splits them: each is
734
+ // { words, input } with quotes removed, redirection targets and comments left
735
+ // out, and `input` the heredoc bodies and here-strings it reads. `nested` holds
736
+ // the text of every $(...) and backtick, which the shell also runs.
737
+ function shellCommands(command) {
738
+ const s = String(command || "");
739
+ const commands = [];
740
+ const nested = [];
741
+ let words = [];
742
+ let input = [];
743
+ let word = null;
744
+ let target = false; // the next word is a redirection's file or descriptor
745
+ let hereString = false; // the next word is a here-string (<<<)
746
+ let heredocs = []; // heredocs whose bodies start after the next newline
747
+ const endWord = () => {
748
+ if (word === null) return;
749
+ if (target) target = false;
750
+ else if (hereString) { hereString = false; input.push(word); }
751
+ else words.push(word);
752
+ word = null;
753
+ };
754
+ const endCommand = () => {
755
+ endWord();
756
+ if (words.length) commands.push({ words, input });
757
+ words = [];
758
+ input = [];
759
+ target = false;
760
+ hereString = false;
761
+ };
762
+ const backtick = (i) => {
763
+ let j = i + 1;
764
+ while (j < s.length && s[j] !== "`") j += s[j] === "\\" ? 2 : 1;
765
+ nested.push(s.slice(i + 1, Math.min(j, s.length)));
766
+ return j + 1;
767
+ };
768
+ let i = 0;
769
+ while (i < s.length) {
770
+ const c = s[i];
771
+ if (c === "\\") {
772
+ if (s[i + 1] === "\n") { i += 2; continue; } // a line continuation
773
+ if (i + 1 < s.length) word = (word || "") + s[i + 1];
774
+ i += 2;
775
+ continue;
776
+ }
777
+ if (c === "#" && word === null) { // a comment, to the end of the line
778
+ while (i < s.length && s[i] !== "\n") i += 1;
779
+ continue;
780
+ }
781
+ if (c === "'") {
782
+ const end = s.indexOf("'", i + 1) < 0 ? s.length : s.indexOf("'", i + 1);
783
+ word = (word || "") + s.slice(i + 1, end);
784
+ i = end + 1;
785
+ continue;
786
+ }
787
+ if (c === '"') {
788
+ let j = i + 1;
789
+ let text = "";
790
+ while (j < s.length && s[j] !== '"') {
791
+ if (s[j] === "\\" && j + 1 < s.length) {
792
+ if (s[j + 1] !== "\n") text += /[$`"\\]/.test(s[j + 1]) ? s[j + 1] : s[j] + s[j + 1];
793
+ j += 2;
794
+ continue;
795
+ }
796
+ if (s[j] === "$" && s[j + 1] === "(") {
797
+ const end = substitutionEnd(s, j + 2);
798
+ nested.push(s.slice(j + 2, end));
799
+ text += s.slice(j, end + 1);
800
+ j = end + 1;
801
+ continue;
802
+ }
803
+ if (s[j] === "`") {
804
+ const after = backtick(j);
805
+ text += s.slice(j, after);
806
+ j = after;
807
+ continue;
808
+ }
809
+ text += s[j];
810
+ j += 1;
811
+ }
812
+ word = (word || "") + text;
813
+ i = j + 1;
814
+ continue;
815
+ }
816
+ if (c === "`") {
817
+ const after = backtick(i);
818
+ word = (word || "") + s.slice(i, after);
819
+ i = after;
820
+ continue;
821
+ }
822
+ if (c === "$" && s[i + 1] === "(") {
823
+ const end = substitutionEnd(s, i + 2);
824
+ nested.push(s.slice(i + 2, end));
825
+ word = (word || "") + s.slice(i, end + 1);
826
+ i = end + 1;
827
+ continue;
828
+ }
829
+ if (c === "<" && s.startsWith("<<<", i)) { endWord(); hereString = true; i += 3; continue; }
830
+ if (c === "<" && s[i + 1] === "<") { // a heredoc: its body follows the next newline
831
+ endWord();
832
+ i += 2;
833
+ const strip = s[i] === "-";
834
+ if (strip) i += 1;
835
+ while (s[i] === " " || s[i] === "\t") i += 1;
836
+ let delim = "";
837
+ while (i < s.length && !/[\s;&|<>()]/.test(s[i])) {
838
+ if (s[i] === "'" || s[i] === '"') {
839
+ const q = s[i];
840
+ const end = s.indexOf(q, i + 1) < 0 ? s.length : s.indexOf(q, i + 1);
841
+ delim += s.slice(i + 1, end);
842
+ i = end + 1;
843
+ } else {
844
+ if (s[i] !== "\\") delim += s[i];
845
+ i += 1;
846
+ }
847
+ }
848
+ if (delim) heredocs.push({ delim, strip, input });
849
+ continue;
850
+ }
851
+ if (c === "<" || c === ">") { // a redirection: the word after it is a file, not an argument
852
+ if (word !== null && /^\d+$/.test(word)) word = null; // the descriptor of 2>, 1>&...
853
+ endWord();
854
+ i += /[>&|]/.test(s[i + 1] || "") ? 2 : 1;
855
+ target = true;
856
+ continue;
857
+ }
858
+ if (c === "\n" || c === "\r") {
859
+ endCommand();
860
+ i += 1;
861
+ if (c === "\n" && heredocs.length) {
862
+ for (const doc of heredocs) {
863
+ const body = [];
864
+ while (i < s.length) {
865
+ const eol = s.indexOf("\n", i) < 0 ? s.length : s.indexOf("\n", i);
866
+ let line = s.slice(i, eol);
867
+ if (doc.strip) line = line.replace(/^\t+/, "");
868
+ i = eol + 1;
869
+ if (line === doc.delim) break;
870
+ body.push(line);
871
+ }
872
+ doc.input.push(body.join("\n"));
873
+ }
874
+ heredocs = [];
875
+ }
876
+ continue;
877
+ }
878
+ if (/[;&|()]/.test(c)) { endCommand(); i += 1; continue; }
879
+ if (/\s/.test(c)) { endWord(); i += 1; continue; }
880
+ word = (word || "") + c;
881
+ i += 1;
882
+ }
883
+ endCommand();
884
+ return { commands, nested };
885
+ }
886
+
887
+ // The index of the command's own name, behind env assignments, shell words and
888
+ // wrappers with their options (-1 when there is none). A wrapper option whose
889
+ // value is a command line (npx -c, env -S) is added to `scripts`.
890
+ function commandWordIndex(words, scripts) {
891
+ let k = 0;
892
+ for (let guard = 0; guard < 16 && k < words.length; guard += 1) {
893
+ const w = words[k];
894
+ if (ENV_ASSIGNMENT.test(w) || SHELL_WORDS.has(w)) { k += 1; continue; }
895
+ const name = baseName(w);
896
+ const valueOptions = COMMAND_WRAPPERS.get(name);
897
+ if (!valueOptions) return k;
898
+ k += 1;
899
+ let positional = name === "timeout" ? 1 : 0; // timeout DURATION command
900
+ while (k < words.length) {
901
+ const arg = words[k];
902
+ if (arg === "--") { k += 1; break; }
903
+ if (name === "env" && ENV_ASSIGNMENT.test(arg)) { k += 1; continue; }
904
+ if (arg.length > 1 && arg.startsWith("-")) {
905
+ if (valueOptions.includes(arg)) {
906
+ if (WRAPPER_SCRIPT_OPTIONS.has(arg) && words[k + 1] !== undefined) scripts.push(words[k + 1]);
907
+ k += 2;
908
+ } else {
909
+ k += 1;
910
+ }
911
+ continue;
912
+ }
913
+ if (positional > 0) { positional -= 1; k += 1; continue; }
914
+ break;
915
+ }
916
+ }
917
+ return k < words.length ? k : -1;
918
+ }
919
+
920
+ // agent-browser's subcommand and the word after it (sub is null when there is
921
+ // none, e.g. --help or --version).
922
+ function agentBrowserCall(words, at) {
923
+ let j = at + 1;
924
+ while (j < words.length) {
925
+ const arg = words[j];
926
+ if (arg === "--") { j += 1; continue; }
927
+ if (arg.startsWith("-")) {
928
+ if (!arg.includes("=") && AGENT_BROWSER_VALUE_FLAGS.has(arg)) j += 2;
929
+ else j += /^(true|false)$/.test(words[j + 1] || "") ? 2 : 1; // boolean flags take an optional true/false
930
+ continue;
931
+ }
932
+ return { sub: arg, sub2: words[j + 1] === undefined ? null : words[j + 1] };
933
+ }
934
+ return { sub: null, sub2: null };
935
+ }
936
+
937
+ // Every agent-browser invocation in a command: [{ sub, sub2 }].
938
+ function agentBrowserInvocations(command, depth = 0) {
939
+ const calls = [];
940
+ if (depth > 4) return calls;
941
+ const { commands, nested } = shellCommands(command);
942
+ const scripts = [...nested];
943
+ for (const { words, input } of commands) {
944
+ const at = commandWordIndex(words, scripts);
945
+ if (at < 0) continue;
946
+ const name = baseName(words[at]);
947
+ if (AGENT_BROWSER_BIN.test(name)) {
948
+ calls.push(agentBrowserCall(words, at));
949
+ } else if (name === "eval") {
950
+ scripts.push(words.slice(at + 1).join(" "));
951
+ } else if (SHELLS.has(name)) {
952
+ // sh -c '<script>' [name args], else the script a shell reads from its heredoc/here-string.
953
+ let script = null;
954
+ for (let j = at + 1; j < words.length; j += 1) {
955
+ const w = words[j];
956
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) {
957
+ script = words.slice(j + 1).find((x) => !x.startsWith("-"));
958
+ break;
959
+ }
960
+ if (w === "-o" || w === "+o" || w === "-O" || w === "+O") { j += 1; continue; }
961
+ if (!w.startsWith("-") && !w.startsWith("+")) break; // a script file
962
+ }
963
+ if (script != null) scripts.push(script);
964
+ else scripts.push(...input);
965
+ }
966
+ }
967
+ for (const script of scripts) calls.push(...agentBrowserInvocations(script, depth + 1));
968
+ return calls;
969
+ }
970
+
971
+ function readOnlyBrowserCall(call) {
972
+ if (!call.sub) return true; // no subcommand: usage or version
973
+ if (AGENT_BROWSER_READ_ONLY.has(call.sub)) return true;
974
+ return call.sub === "session" && call.sub2 === "list";
975
+ }
976
+
977
+ // null, or { subcommand, message } when a live driver lock refuses this command.
978
+ function browserDriverDenial(command, now = Date.now()) {
979
+ try {
980
+ const cmd = String(command || "");
981
+ if (!cmd.includes("agent-browser")) return null;
982
+ const lock = require("./browser-lock").liveLock(now);
983
+ if (!lock) return null;
984
+ const blocked = agentBrowserInvocations(cmd).find((call) => !readOnlyBrowserCall(call));
985
+ if (!blocked) return null;
986
+ const name = String(lock.name || "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || "Someone";
987
+ return {
988
+ subcommand: String(blocked.sub || "").slice(0, 40),
989
+ message: `${name} is driving the browser right now. Wait for them to hand it back; you will be woken in their chat when they do.`,
990
+ };
991
+ } catch (e) {
992
+ return null;
993
+ }
994
+ }
995
+
662
996
  // Claude sends the shell command as a string; accept an argv array too.
663
997
  function shellCommandOf(input) {
664
998
  if (!input || typeof input !== "object") return null;
@@ -727,6 +1061,14 @@ function runHook(payload) {
727
1061
  logGuardEvent({ kind: "deny", rule: "external-secret-read", pattern: secretDenial.rule, command: String(command).slice(0, 300) });
728
1062
  return { exit: 2, stderr: secretDenial.message };
729
1063
  }
1064
+ // A person is driving the browser (Browser app): ahead of the tooling-mode
1065
+ // switch, so relaxed mode cannot drive over them either. Only the
1066
+ // subcommand is logged: the bot's own fill/type commands can carry text.
1067
+ const driving = browserDriverDenial(command);
1068
+ if (driving) {
1069
+ logGuardEvent({ kind: "deny", rule: "browser-driver", subcommand: driving.subcommand });
1070
+ return { exit: 2, stderr: driving.message };
1071
+ }
730
1072
  // Relaxed tooling mode: the gate observes but never blocks. The event is
731
1073
  // still logged (as relaxed-pass, not deny) so the dream KPI can report
732
1074
  // what WOULD have been denied — visibility survives the mode switch.
@@ -761,4 +1103,5 @@ module.exports = {
761
1103
  externalFileMutationDenial, externalControlPlaneDenial, guardedSpeakerFromEnv,
762
1104
  protectedReadRoots, isProtectedReadPath, isProtectedSearchRoot, secretReadRule,
763
1105
  matchRawOp, noteRawOp, readGuardEvents,
1106
+ agentBrowserInvocations, browserDriverDenial,
764
1107
  };
@@ -25,6 +25,9 @@
25
25
  // (6) on SIGTERM, whose sender kills soon after; whatever is still
26
26
  // running stays journaled.
27
27
  // - /stop offers each dropped queued message back with a "Run it" button.
28
+ // - /stop marks the conversation "stopped" (kept across a restart): its next
29
+ // turn is told the stopped request is cancelled, not cut off (see
30
+ // markStopped).
28
31
  // Scheduled runs (wake-ups, crons, watches) are not journaled: jobs.json keeps
29
32
  // their own retry, so they are never resumed twice.
30
33
 
@@ -78,7 +81,7 @@ function remove(file) { try { fs.unlinkSync(file); } catch (_) {} }
78
81
 
79
82
  // Persist the record, or drop the file when nothing is left in it.
80
83
  function store(file, record) {
81
- if (!record.turn && !(record.queued || []).length) return remove(file);
84
+ if (!record.turn && !(record.queued || []).length && !record.stopped) return remove(file);
82
85
  write(file, record);
83
86
  }
84
87
 
@@ -164,6 +167,8 @@ function begin(runContext, opts = {}) {
164
167
  },
165
168
  queued: (existing.queued || []).filter((q) => q.runId !== runContext.runId),
166
169
  };
170
+ // A stop this turn has not taken yet stays for the turn that does.
171
+ if (existing.stopped) record.stopped = existing.stopped;
167
172
  try { write(file, record); } catch (error) { console.warn(`[turn-journal] could not journal ${runContext.laneKey}: ${error.message}`); return false; }
168
173
  active.set(runContext.laneKey, file);
169
174
  ensureHeartbeat();
@@ -205,6 +210,52 @@ function dropQueued(laneKey, runIds) {
205
210
  store(file, record);
206
211
  }
207
212
 
213
+ // ── /stop: a cancel, not a cut ─────────────────────────────────────────
214
+ // /stop kills the provider mid-turn. On the next message Claude Code resumes
215
+ // the session and writes the cut turn into the history the same way a
216
+ // restart's cut reads ("[Request interrupted by user for tool use]", then
217
+ // "Continue from where you left off." / "No response requested."), and the
218
+ // system prompt says to finish work a restart cut off. So a stopped request
219
+ // came back on the next message (home pi, 9 Oct: "/stop" on "ok play big
220
+ // bang", then "Big Bang didn't start … just say if you still want it"). The
221
+ // stop is kept in the conversation's file until its next turn takes it.
222
+ function markStopped(laneKey, opts = {}) {
223
+ if (!journalOn() || !laneKey) return false;
224
+ const file = fileFor(laneKey);
225
+ const record = read(file) || { v: 1, conversation: null, turn: null, queued: [] };
226
+ if (!record.conversation && opts.runContext?.origin) record.conversation = conversationOf(opts.runContext);
227
+ record.stopped = {
228
+ at: Number.isFinite(opts.now) ? opts.now : Date.now(),
229
+ runId: opts.runContext?.runId || null,
230
+ };
231
+ try { write(file, record); } catch (_) { return false; }
232
+ return true;
233
+ }
234
+
235
+ // The conversation's stop, once: the turn that takes it carries the note.
236
+ function takeStopped(laneKey) {
237
+ if (!journalOn() || !laneKey) return null;
238
+ const file = fileFor(laneKey);
239
+ const record = read(file);
240
+ if (!record?.stopped) return null;
241
+ const stopped = record.stopped;
242
+ delete record.stopped;
243
+ try { store(file, record); } catch (_) {}
244
+ return stopped;
245
+ }
246
+
247
+ // /new, /end or a project change: the stopped turn's history is gone.
248
+ function clearStopped(laneKey) {
249
+ takeStopped(laneKey);
250
+ }
251
+
252
+ // What the turn after a /stop reads before the person's message.
253
+ function stoppedNote(stopped) {
254
+ const at = Number(stopped?.at);
255
+ const when = Number.isFinite(at) && at > 0 ? `At ${hhmm(at)} the person` : "The person";
256
+ return `[${when} stopped your previous turn with /stop, so that request is cancelled. Your history may still show it unfinished, followed by "Continue from where you left off." from the agent CLI (not from them). Don't resume it, redo it or offer to do it again unless they ask; just answer the message below.]`;
257
+ }
258
+
208
259
  function list() {
209
260
  if (!journalOn()) return [];
210
261
  let names = [];
@@ -337,7 +388,8 @@ async function resumeInterrupted(adapters, overrides = {}) {
337
388
  if (!turn) {
338
389
  const queued = record.queued || [];
339
390
  record.queued = [];
340
- remove(file);
391
+ // A stop not yet taken stays (the restored messages' turn takes it).
392
+ store(file, record);
341
393
  runQueued({ ...record, queued }, adapter, deps);
342
394
  out.restored += queued.length;
343
395
  continue;
@@ -502,6 +554,7 @@ module.exports = {
502
554
  conversationOf, chatScope, findAdapter, defaultDeps,
503
555
  resumeInterrupted, handleResumeAction, RESUME_PROMPT,
504
556
  rememberDropped, droppedKeyboard, handleRunDropped,
557
+ markStopped, takeStopped, clearStopped, stoppedNote,
505
558
  drain, exitAfterDrain, isDraining, markShuttingDown,
506
559
  resumeEnabled, maxAgeMs, drainTimeoutMs, sigtermDrainTimeoutMs, MAX_ATTEMPTS, HEARTBEAT_MS,
507
560
  _resetForTests,
@@ -302,9 +302,119 @@ function limitHitDetails({ provider, error, rateLimit = null, windows = [], now
302
302
  };
303
303
  }
304
304
 
305
+ // ---------------------------------------------------------------------------
306
+ // Waiting for a reset: one rule and one record for every kind of work
307
+ // (Sumeet, 9 Oct 2026: a used-up limit is handled the same way everywhere,
308
+ // as a runtime concept, not a fix per feature).
309
+ //
310
+ // - limitRetryAt() is when work a limit stopped runs again: a chat message
311
+ // (core/limit-pickup), a scheduled run (core/scheduler), the dream's AI
312
+ // step (core/dream). Never another provider: the other one may not be
313
+ // signed in or may be used up too.
314
+ // - noteLimitHit() records that a provider is used up until its reset,
315
+ // from any run a limit stopped; a run that works clears it
316
+ // (clearLimitBlock). Background calls read it (limitBlock) and do not
317
+ // start a used-up provider again and again. Chat turns never read it:
318
+ // they always try, so the provider stays the judge.
319
+
320
+ function setting(name) {
321
+ if (process.env[name] !== undefined) return process.env[name];
322
+ return config[name];
323
+ }
324
+
325
+ // Wait this long after the reset, so the provider's window has turned over.
326
+ function limitRetryDelayMs() {
327
+ const n = Number(setting("LIMIT_PICKUP_DELAY_S"));
328
+ return (Number.isFinite(n) && n >= 0 ? n : 120) * 1000;
329
+ }
330
+
331
+ // A reset further out than this (a monthly spend cap) is not waited for.
332
+ function limitMaxWaitMs() {
333
+ const n = Number(setting("LIMIT_PICKUP_MAX_WAIT_H"));
334
+ return (Number.isFinite(n) && n > 0 ? n : 192) * 3600000;
335
+ }
336
+
337
+ /** When work a limit stopped should run again; null when the reset is not known. */
338
+ function limitRetryAt(resetsAt, { now = Date.now() } = {}) {
339
+ if (!Number.isFinite(resetsAt) || resetsAt <= now - 6 * 3600000) return null;
340
+ return Math.max(resetsAt, now) + limitRetryDelayMs();
341
+ }
342
+
343
+ /** Whether a wait until `runAt` is short enough to wait for. */
344
+ function canWaitFor(runAt, { now = Date.now() } = {}) {
345
+ return Number.isFinite(runAt) && runAt - now <= limitMaxWaitMs();
346
+ }
347
+
348
+ function liveFile(file) {
349
+ // A test run that did not point the config dir somewhere else never touches
350
+ // the live bot's file.
351
+ return !(file === DEFAULT_FILE && process.env.OPEN_CLAUDIA_TEST === "1" && !process.env.OPEN_CLAUDIA_CONFIG_DIR);
352
+ }
353
+
354
+ // A model-family limit (Claude's weekly Opus or Sonnet limit) stops only that
355
+ // family; an unknown model counts as stopped.
356
+ function blocksModel(limitType, model) {
357
+ const family = /opus|sonnet|fable|haiku/i.exec(String(limitType || ""));
358
+ if (!family || !model) return true;
359
+ return String(model).toLowerCase().includes(family[0].toLowerCase());
360
+ }
361
+
362
+ /** Record that `provider` is used up until `resetsAt` (a run hit its limit). */
363
+ function noteLimitHit({ provider, limitType = null, resetsAt, file = DEFAULT_FILE, now = Date.now() } = {}) {
364
+ if (!provider || !Number.isFinite(resetsAt) || resetsAt <= now || !liveFile(file)) return null;
365
+ return withFileLock(file, () => {
366
+ const state = readState(file);
367
+ if (!state.blocks || typeof state.blocks !== "object") state.blocks = {};
368
+ const previous = state.blocks[provider];
369
+ const block = {
370
+ limitType: limitType || null,
371
+ resetsAt,
372
+ since: previous && previous.resetsAt > now ? previous.since : new Date(now).toISOString(),
373
+ updatedAt: new Date(now).toISOString(),
374
+ };
375
+ state.blocks[provider] = block;
376
+ atomicWriteFileSync(file, JSON.stringify(state, null, 2) + "\n");
377
+ return { provider, ...block };
378
+ });
379
+ }
380
+
381
+ /** The limit `provider` is used up on now, or null. `model` narrows a model-family limit. */
382
+ function limitBlock(provider, { model = null, file = DEFAULT_FILE, now = Date.now() } = {}) {
383
+ if (!provider || !liveFile(file)) return null;
384
+ const block = readState(file).blocks?.[provider];
385
+ if (!block || !Number.isFinite(block.resetsAt) || block.resetsAt <= now) return null;
386
+ if (!blocksModel(block.limitType, model)) return null;
387
+ return { provider, ...block };
388
+ }
389
+
390
+ /**
391
+ * A run on `provider` worked: it is not used up (any more). A run on a model
392
+ * a model-family limit does not cover proves nothing about that limit.
393
+ */
394
+ function clearLimitBlock(provider, { model = null, file = DEFAULT_FILE } = {}) {
395
+ if (!provider || !liveFile(file)) return false;
396
+ // Read first: almost every run finds nothing to clear and writes nothing.
397
+ const found = readState(file).blocks?.[provider];
398
+ if (!found || !blocksModel(found.limitType, model)) return false;
399
+ return withFileLock(file, () => {
400
+ const state = readState(file);
401
+ if (!state.blocks?.[provider]) return false;
402
+ delete state.blocks[provider];
403
+ atomicWriteFileSync(file, JSON.stringify(state, null, 2) + "\n");
404
+ return true;
405
+ });
406
+ }
407
+
305
408
  module.exports = {
306
409
  DEFAULT_FILE,
307
410
  DEFAULT_WARN_PERCENTS,
411
+ limitRetryDelayMs,
412
+ limitMaxWaitMs,
413
+ limitRetryAt,
414
+ canWaitFor,
415
+ noteLimitHit,
416
+ limitBlock,
417
+ clearLimitBlock,
308
418
  warnPercents,
309
419
  providerLabel,
310
420
  windowLabel,