@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.
- package/.env.example +8 -3
- package/CHANGELOG.md +37 -0
- package/README.md +5 -4
- package/bin/cli.js +6 -0
- package/bin/teach.js +78 -0
- package/bot.js +7 -0
- package/channels/kazee/adapter.js +30 -0
- package/core/actions.js +4 -0
- package/core/auth-flow.js +7 -2
- package/core/bot-apps.js +133 -0
- package/core/browser-control.js +540 -0
- package/core/browser-lock.js +51 -0
- package/core/browser-teach.js +1012 -0
- package/core/browser-view.js +5 -2
- package/core/cards.js +4 -2
- package/core/dream.js +132 -10
- package/core/handlers.js +16 -2
- package/core/limit-pickup.js +24 -20
- package/core/providers/claude.js +15 -8
- package/core/providers/events.js +25 -1
- package/core/providers/utility-policy.js +31 -6
- package/core/runner.js +203 -10
- package/core/scheduler.js +57 -0
- package/core/state.js +14 -0
- package/core/system-prompt.js +12 -2
- package/core/tool-guard.js +343 -0
- package/core/turn-journal.js +55 -2
- package/core/usage-limits.js +110 -0
- package/core/utility-agent.js +109 -15
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +46 -2
- package/test-provider-boot-matrix.js +6 -2
- package/test-provider-capabilities.js +1 -1
- package/test-provider-claude.js +7 -5
- package/test-provider-events.js +43 -1
- package/test-provider-language.js +15 -1
- package/web.js +68 -0
package/core/tool-guard.js
CHANGED
|
@@ -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
|
};
|
package/core/turn-journal.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/core/usage-limits.js
CHANGED
|
@@ -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,
|