@inetafrica/open-claudia 3.5.18 → 3.5.20

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.
@@ -98,13 +98,15 @@ function listSessions({ dirs = socketDirs(), alive = processAlive } = {}) {
98
98
  } catch (_) { /* try the next file */ }
99
99
  }
100
100
  seen.add(name);
101
- sessions.push({ name, port, page, updatedAt });
101
+ // dir: the socket folder, so the bot's own agent-browser calls (core/browser-teach.js) reach
102
+ // this session and never start a new one. Internal like the port.
103
+ sessions.push({ name, port, page, updatedAt, dir });
102
104
  }
103
105
  }
104
106
  return sessions.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
105
107
  }
106
108
 
107
- // Public view of a session: the stream port stays inside the bot.
109
+ // Public view of a session: the stream port and socket folder stay inside the bot.
108
110
  function describeSessions(options) {
109
111
  return listSessions(options).map(({ name, page, updatedAt }) => ({ name, page, updatedAt }));
110
112
  }
@@ -189,6 +191,7 @@ function serveStream(res, sessionName, { fps, sessions, connect, keepaliveMs = K
189
191
  module.exports = {
190
192
  DEFAULT_FPS,
191
193
  MAX_FPS,
194
+ SESSION_NAME,
192
195
  clampFps,
193
196
  describeSessions,
194
197
  listSessions,
package/core/cards.js CHANGED
@@ -255,7 +255,9 @@ function describeResponse(rec, payload, now = Date.now()) {
255
255
  // ---------------------------------------------------------------------------
256
256
  // Waking the conversation a card (or decision) came from
257
257
 
258
- function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500 } = {}) {
258
+ // `source` tags the job for job lists and audits: "card" for card answers,
259
+ // "browser" for the Browser app's hand-backs and Teach-me recordings.
260
+ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500, source = "card" } = {}) {
259
261
  if (!origin || !origin.channelId || !origin.provider || !origin.project || !origin.canonicalUserId) {
260
262
  throw new Error("the card's origin conversation is unknown");
261
263
  }
@@ -282,7 +284,7 @@ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500 } = {}) {
282
284
  },
283
285
  prompt,
284
286
  label: String(label).slice(0, 60),
285
- source: "card",
287
+ source,
286
288
  fireAt: Date.now() + delayMs,
287
289
  });
288
290
  }
@@ -17,23 +17,32 @@ const GATE_MIN_SCORE = Number(process.env.PACK_DEDUP_GATE_SCORE || 25);
17
17
  const ADJUDICATOR_TIMEOUT_MS = Number(process.env.PACK_DEDUP_TIMEOUT_MS || 45000);
18
18
  const MAX_CANDIDATES_PER_FACT = 3;
19
19
 
20
+ // The root must be an object: Claude Code turns the schema into a tool whose
21
+ // input_schema the API requires to be an object. The bare array this used to
22
+ // be got a 400 on every call from 3.2.2 to 3.5.18, so dedup failed open.
20
23
  const ADJUDICATOR_OUTPUT_SCHEMA = Object.freeze({
21
- type: "array",
22
- items: {
23
- type: "object",
24
- required: ["id", "verdict"],
25
- properties: {
26
- id: { type: "string" },
27
- verdict: { type: "string", enum: ["store", "update", "merge", "skip"] },
28
- target: { type: "string" },
29
- merged: { type: "string" },
24
+ type: "object",
25
+ required: ["verdicts"],
26
+ properties: {
27
+ verdicts: {
28
+ type: "array",
29
+ items: {
30
+ type: "object",
31
+ required: ["id", "verdict"],
32
+ properties: {
33
+ id: { type: "string" },
34
+ verdict: { type: "string", enum: ["store", "update", "merge", "skip"] },
35
+ target: { type: "string" },
36
+ merged: { type: "string" },
37
+ },
38
+ },
30
39
  },
31
40
  },
32
41
  });
33
42
 
34
43
  const ADJUDICATOR_SYSTEM_PROMPT = [
35
44
  "You are a dedup judge for an assistant's long-term memory.",
36
- "Reply with ONLY a JSON array of verdicts — no prose, no markdown fences.",
45
+ 'Reply with ONLY a JSON object {"verdicts": [...]} — no prose, no markdown fences.',
37
46
  ].join("\n");
38
47
 
39
48
  function enabled() {
@@ -113,20 +122,29 @@ function buildAdjudicationPrompt(pairs) {
113
122
  "",
114
123
  ...blocks,
115
124
  "",
116
- `Reply with ONLY a JSON array like [{"id":"f1","verdict":"store"}] covering every FACT id.`,
125
+ `Reply with ONLY a JSON object like {"verdicts":[{"id":"f1","verdict":"store"}]} covering every FACT id.`,
117
126
  ].join("\n");
118
127
  }
119
128
 
129
+ // The reply is {"verdicts": [...]}; a bare array is still read.
130
+ function verdictList(value) {
131
+ if (Array.isArray(value)) return value;
132
+ return value && typeof value === "object" && Array.isArray(value.verdicts) ? value.verdicts : null;
133
+ }
134
+
120
135
  function parseVerdicts(result) {
121
- if (Array.isArray(result?.json)) return result.json;
122
- const match = String(result?.text || "").match(/\[[\s\S]*\]/);
123
- if (!match) return null;
124
- try {
125
- const parsed = JSON.parse(match[0]);
126
- return Array.isArray(parsed) ? parsed : null;
127
- } catch (_) {
128
- return null;
136
+ const fromJson = verdictList(result?.json);
137
+ if (fromJson) return fromJson;
138
+ const text = String(result?.text || "");
139
+ for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*\]/]) {
140
+ const match = text.match(pattern);
141
+ if (!match) continue;
142
+ try {
143
+ const list = verdictList(JSON.parse(match[0]));
144
+ if (list) return list;
145
+ } catch (_) { /* try the other shape */ }
129
146
  }
147
+ return null;
130
148
  }
131
149
 
132
150
  // Phase 3 — turn raw verdicts into a transformed decision plus replacement
@@ -220,6 +238,11 @@ function createDedupAdjudicator(dependencies = {}) {
220
238
  });
221
239
  rawVerdicts = parseVerdicts(result) || [];
222
240
  llmUsed = true;
241
+ // One line per judgement, so its verdicts can be checked in the log
242
+ // (before 3.5.19 it logged only failures, and failed on every call).
243
+ const counts = {};
244
+ for (const v of rawVerdicts) if (v && typeof v.verdict === "string") counts[v.verdict] = (counts[v.verdict] || 0) + 1;
245
+ console.log(`[dedup-adjudicator] judged ${pairs.length} fact${pairs.length === 1 ? "" : "s"}: ${Object.entries(counts).map(([verdict, n]) => `${n} ${verdict}`).join(", ") || "no verdicts"}`);
223
246
  } catch (error) {
224
247
  console.warn(`[dedup-adjudicator] failed open: ${error.message}`);
225
248
  rawVerdicts = [];
@@ -7,9 +7,15 @@
7
7
  const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
8
8
  const MESSAGE_CLIP_CHARS = 1500;
9
9
  const CONTEXT_CLIP_CHARS = 3500;
10
+ // The root must be an object: Claude Code turns the schema into a tool whose
11
+ // input_schema the API requires to be an object, so the bare array this used
12
+ // to be got a 400 on every Claude call and the filter failed open.
10
13
  const FILTER_OUTPUT_SCHEMA = Object.freeze({
11
- type: "array",
12
- items: { type: "string" },
14
+ type: "object",
15
+ required: ["relevant"],
16
+ properties: {
17
+ relevant: { type: "array", items: { type: "string" } },
18
+ },
13
19
  });
14
20
 
15
21
  function enabled() {
@@ -18,15 +24,28 @@ function enabled() {
18
24
 
19
25
  const FILTER_SYSTEM_PROMPT = [
20
26
  "You are a relevance judge for an assistant's memory recall.",
21
- "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
27
+ 'Reply with ONLY a JSON object {"relevant": [candidate ids]} — no prose, no markdown fences.',
22
28
  ].join("\n");
23
29
 
30
+ // The reply is {"relevant": [...]}; a bare array is still read.
31
+ function idList(value) {
32
+ if (Array.isArray(value)) return value;
33
+ return value && typeof value === "object" && Array.isArray(value.relevant) ? value.relevant : null;
34
+ }
35
+
24
36
  function parseIds(result) {
25
- if (Array.isArray(result?.json)) return result.json;
26
- const match = String(result?.text || "").match(/\[[\s\S]*?\]/);
27
- if (!match) return null;
28
- const parsed = JSON.parse(match[0]);
29
- return Array.isArray(parsed) ? parsed : null;
37
+ const fromJson = idList(result?.json);
38
+ if (fromJson) return fromJson;
39
+ const text = String(result?.text || "");
40
+ for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*?\]/]) {
41
+ const match = text.match(pattern);
42
+ if (!match) continue;
43
+ try {
44
+ const ids = idList(JSON.parse(match[0]));
45
+ if (ids) return ids;
46
+ } catch (_) { /* try the other shape */ }
47
+ }
48
+ return null;
30
49
  }
31
50
 
32
51
  function createRecallFilter(dependencies = {}) {
@@ -62,7 +81,7 @@ function createRecallFilter(dependencies = {}) {
62
81
  "Candidates:",
63
82
  ...lines,
64
83
  "",
65
- `Reply with ONLY a JSON array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
84
+ `Reply with ONLY a JSON object of the relevant ids, e.g. {"relevant": ["${candidates[0].id}"]}. Use {"relevant": []} if none are relevant.`,
66
85
  ].join("\n");
67
86
  try {
68
87
  const result = await runUtility(prompt, {
@@ -239,6 +239,16 @@ Raw shell one-liners and heredocs are for probing and diagnosis only — never t
239
239
  ${listing}`;
240
240
  }
241
241
 
242
+ // Kazee Chat's Browser app (core/bot-apps.js): only on a bot that offers it, so a bot without
243
+ // Agent Space is never told to send an Open Browser button that cannot open.
244
+ function buildBrowserAppBlock() {
245
+ let apps = null;
246
+ try { apps = require("./bot-apps").appsToRegister(process.env); } catch (_) { apps = null; }
247
+ if (!apps || !apps.some((app) => app.key === "browser")) return "";
248
+ return `
249
+ Kazee Chat Browser app: people in a direct chat with you can watch this browser live, take it over, and teach you a task (Teach me). When you need a person in the browser (a login, a captcha, a one-time code, a judgement call), send a card in their direct chat with an Open Browser button, say what you need, then wait: you are woken in that chat when they hand it back. For example \`open-claudia card send '{"v":1,"kind":"report","title":"I need you in the browser","body":"<what you need and why>","open":{"app":"browser","label":"Open Browser"}}'\`. While someone drives, your agent-browser commands other than get, snapshot and screenshot are refused. Never ask for passwords in chat.`;
250
+ }
251
+
242
252
  function buildCoreInstructionsUncached() {
243
253
  const soul = loadSoul();
244
254
  // Gate on actual TTS capability, not local Whisper: hosted pods hear and
@@ -368,7 +378,7 @@ Core workflow — open, snapshot to get element refs, then act on those refs:
368
378
  - \`agent-browser eval "<js>"\` — run JS in the page when no command fits.
369
379
  - \`agent-browser close\` — end the session when done.
370
380
 
371
- Notes: a background daemon persists between commands, so navigation state carries across calls in a turn. Prefer \`snapshot -i\` refs over guessing selectors. Keep output lean by acting on refs rather than dumping full HTML. This replaced the old Playwright MCP — do not look for a playwright tool.
381
+ Notes: a background daemon persists between commands, so navigation state carries across calls in a turn. Prefer \`snapshot -i\` refs over guessing selectors. Keep output lean by acting on refs rather than dumping full HTML. This replaced the old Playwright MCP — do not look for a playwright tool.${buildBrowserAppBlock()}
372
382
 
373
383
  If you tell the user "I'll check back in N minutes" or "I'll run this every morning", you MUST schedule it with one of the commands above in the same turn. Otherwise nothing happens.
374
384
 
@@ -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
  };
@@ -123,6 +123,13 @@ function validateSchema(value, schema, location = "$", depth = 0) {
123
123
 
124
124
  function prepareSchemaFile(schema) {
125
125
  if (!schema) return null;
126
+ // Claude Code turns the schema into a tool whose input_schema the API
127
+ // requires to be an object, and OpenAI strict mode wants an object root too.
128
+ // Any other root is refused on every call, which a fail-open caller shows
129
+ // only as a warning (the dedup adjudicator's array root, 3.2.2 to 3.5.18).
130
+ if (typeof schema !== "object" || Array.isArray(schema) || schema.type !== "object") {
131
+ throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", 'outputSchema must have top-level type "object"');
132
+ }
126
133
  let serialized;
127
134
  try { serialized = JSON.stringify(schema); }
128
135
  catch (_) { throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", "outputSchema must be JSON-serializable"); }