hilos-agent 0.9.5 → 0.10.1

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/README.md CHANGED
@@ -50,6 +50,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
50
50
  "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
51
51
  "codingModel": "", // Codex tier ("most-capable" | "balanced" | "fastest"), resolved against this account's own model list; "" = the tool's default
52
52
  "chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
53
+ "webSearch": true, // native public-web tools are on by default; false stops Hilos enablement/instructions
53
54
  "defaultBranch": "main",
54
55
  "gate": false, // default: open a PR directly. true = approve-before-push
55
56
  "heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
@@ -71,10 +72,19 @@ a stderr tail) instead of claiming "no changes". Chat replies use the faster
71
72
  `chatCmd` (when unset, derived from `codingCmd`'s tool — a Claude daemon chats
72
73
  with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
73
74
  `chatTimeoutMs`. The chat-vs-code pass only classifies; the separate chat
74
- responder keeps the CLI's normal tools. Generated Codex chat and code commands
75
- explicitly enable its built-in web search (an explicit operator override still
76
- wins), while other vendors keep their own tool configuration. The responsive
77
- surface needs a hilos server new enough to expose
75
+ responder keeps the CLI's normal tools. Public web is on by default and stays
76
+ native to the selected CLI: Hilos pre-approves Claude Code's read-only
77
+ `WebSearch`/`WebFetch`, enables Codex search, turns on OpenCode's official Exa
78
+ search switch, preserves Hermes's full web-capable coding toolset, and keeps
79
+ Cursor and Antigravity's built-ins. Generated chat replies use Cursor `ask`
80
+ with safe auto-review, OpenCode
81
+ `plan`, Antigravity `plan`, Codex's read-only sandbox, or Hermes `safe,web`
82
+ where available; coding runs keep their normal tool/approval policy. Run
83
+ `hilos-agent web doctor` to inspect the configured contract. Set
84
+ `"webSearch": false`, `HILOS_WEB_SEARCH=0`, or `--no-web-search` to stop Hilos
85
+ from enabling or requesting web use; the CLI's own global/project tool policy
86
+ still applies. Custom commands retain their own tools without guessed flags.
87
+ The responsive surface needs a hilos server new enough to expose
78
88
  `edit_message`; older servers just skip the live edits.
79
89
 
80
90
  ```sh
@@ -82,6 +92,45 @@ hilos-agent # watch every channel the agent is in
82
92
  hilos-agent --channel <id> # scope to one channel
83
93
  ```
84
94
 
95
+ ## WebMCP site tools
96
+
97
+ The local daemon can give its coding and chat agents a narrow bridge to
98
+ third-party WebMCP sites. It is off until you name exact origins and exact read
99
+ tools in the machine's config:
100
+
101
+ ```jsonc
102
+ {
103
+ "webMcp": {
104
+ "origins": {
105
+ "https://docs.example.com": {
106
+ "readTools": ["search_docs", "read_reference"]
107
+ }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ The site does not authorize itself: `readOnlyHint` is informational, while this
114
+ person-owned list decides what may run. Site descriptions are omitted, schema
115
+ prose is stripped, results are bounded and labeled untrusted, and cookies stay
116
+ inside a separate browser profile. The bundled browser bridge currently needs
117
+ Node.js 24 or newer; the rest of the daemon keeps its existing Node.js support.
118
+
119
+ ```sh
120
+ hilos-agent webmcp doctor
121
+ hilos-agent webmcp login https://docs.example.com # person signs in in the opened browser
122
+ hilos-agent webmcp open https://docs.example.com/reference
123
+ hilos-agent webmcp tools
124
+ hilos-agent webmcp call search_docs '{"query":"WebMCP"}'
125
+ hilos-agent webmcp close
126
+ ```
127
+
128
+ The daemon adds this capability and its citation rules to agent prompts only
129
+ when the config is valid. Unlisted tools — including writes — are refused with
130
+ `human_approval_required`; there is no approval flag the agent can set. WebMCP
131
+ can never approve or merge hilos work. See the complete contract in
132
+ [WebMCP in hilos](https://hilos.sh/docs/webmcp).
133
+
85
134
  ## How it works
86
135
 
87
136
  - **Trigger** — an `@mention` of your agent in a channel that's linked to a repo,
@@ -7,6 +7,7 @@
7
7
  // hilos-agent --join <blob> connect with a copy-paste link from hilos
8
8
  // hilos-agent --join-stdin read a private link without exposing it in argv
9
9
  // hilos-agent init write a starter config (~/.hilos/agent.json)
10
+ // hilos-agent webmcp doctor verify the local site-tool bridge
10
11
  // hilos-agent run with the resolved config (default)
11
12
  // hilos-agent run same as above, explicit
12
13
  //
@@ -17,6 +18,7 @@
17
18
  // codingCmd in hilos-agent.json to change it and the daemon picks it up on its
18
19
  // next poll — no restart needed.
19
20
 
21
+ import { spawnSync } from "node:child_process";
20
22
  import { readFileSync } from "node:fs";
21
23
  import { fileURLToPath } from "node:url";
22
24
 
@@ -24,6 +26,9 @@ import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "..
24
26
  import { readPrivateJoin } from "../src/join-input.mjs";
25
27
  import { run } from "../src/run.mjs";
26
28
  import { hookMain, hooksMain } from "../src/hook.mjs";
29
+ import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
30
+ import { detectVendor, fastChatCmd, webCapability } from "../src/progress-emitter.mjs";
31
+ import { commandArgv } from "../src/argv.mjs";
27
32
 
28
33
  function packageVersion() {
29
34
  const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
@@ -44,6 +49,8 @@ function parseArgs(argv) {
44
49
  else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
45
50
  else if (a === "--coding-model") flags.codingModel = argv[++i];
46
51
  else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
52
+ else if (a === "--no-web-search") flags.webSearch = false;
53
+ else if (a === "--web-search") flags.webSearch = true;
47
54
  else if (a === "--once") flags.once = true;
48
55
  else if (a === "--backfill") flags.backfill = true;
49
56
  else if (a === "--no-gate") flags.gate = false;
@@ -67,6 +74,13 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
67
74
  hilos-agent --join <blob> connect using a link copied from hilos
68
75
  hilos-agent --join-stdin paste the private link at a no-echo prompt
69
76
  hilos-agent init write a starter config to ~/.hilos/agent.json
77
+ hilos-agent webmcp doctor verify the local WebMCP browser bridge
78
+ hilos-agent webmcp login <url> open the isolated profile for person sign-in
79
+ hilos-agent webmcp open <url> open a person-allowlisted site for an agent
80
+ hilos-agent webmcp tools list registered, person-allowlisted read tools
81
+ hilos-agent webmcp call <name> '<json object>'
82
+ hilos-agent webmcp close close the isolated browser session
83
+ hilos-agent web doctor report this CLI's native public-web capability
70
84
  hilos-agent run the daemon (watch @mentions, propose diffs)
71
85
  hilos-agent hooks install stream this repo's Codex, Claude, and Cursor
72
86
  sessions to hilos and continue replies in the same
@@ -90,6 +104,7 @@ Options:
90
104
  --chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
91
105
  derived from the coding command, so a Codex or Cursor
92
106
  daemon chats with its own tool)
107
+ --no-web-search stop hilos from enabling/requesting native public web
93
108
  --once one poll then exit (cron-friendly)
94
109
  --backfill also act on mentions that predate startup
95
110
  --no-gate propose only; don't wait for approval / push
@@ -150,6 +165,57 @@ async function main() {
150
165
  return;
151
166
  }
152
167
 
168
+ if (cmd === "webmcp") {
169
+ const cliFlags = { ...flags };
170
+ delete cliFlags.help;
171
+ const cfg = resolveConfig({ flags: cliFlags });
172
+ const operation = positional[1] || "doctor";
173
+ const result = await runWebMcpCommand(cfg, operation, positional.slice(2));
174
+ console.log(JSON.stringify(result, null, 2));
175
+ if (!result.ok) process.exitCode = 1;
176
+ return;
177
+ }
178
+
179
+ if (cmd === "web") {
180
+ const operation = positional[1] || "doctor";
181
+ if (operation !== "doctor") {
182
+ throw new Error(`Unknown web command: ${operation}. Try \`hilos-agent web doctor\`.`);
183
+ }
184
+ const cliFlags = { ...flags };
185
+ delete cliFlags.help;
186
+ const cfg = resolveConfig({ flags: cliFlags });
187
+ const codingCommand = cfg.codingCmd;
188
+ const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(codingCommand)) || codingCommand;
189
+ const describe = (command) => {
190
+ const argv = commandArgv(command);
191
+ const vendor = detectVendor(command);
192
+ const capability = webCapability(vendor, {
193
+ enabled: cfg.webSearch !== false,
194
+ args: argv.slice(1),
195
+ });
196
+ const binary = argv[0] || "";
197
+ const probed = binary
198
+ ? spawnSync(binary, ["--version"], { encoding: "utf8", timeout: 5_000 })
199
+ : null;
200
+ const binaryAvailable = Boolean(binary) && !probed?.error;
201
+ const version = String(probed?.stdout || probed?.stderr || "").trim().split("\n")[0] || null;
202
+ return { vendor, binary, binaryAvailable, version, command, ...capability };
203
+ };
204
+ const chat = describe(chatCommand);
205
+ const code = codingCommand === chatCommand ? chat : describe(codingCommand);
206
+ console.log(JSON.stringify({
207
+ ok: [chat, code].every((lane) => lane.status === "enabled" && lane.binaryAvailable),
208
+ chat,
209
+ code,
210
+ note: !chat.binaryAvailable || !code.binaryAvailable
211
+ ? "One or more selected CLI binaries are not installed or not on PATH."
212
+ : chat.verified && code.verified
213
+ ? "Configured by hilos-agent; this does not spend a model call or test provider credentials."
214
+ : "Custom commands keep their own tool configuration; hilos-agent does not guess flags.",
215
+ }, null, 2));
216
+ return;
217
+ }
218
+
153
219
  // run (default) — when --join is passed without init, connect straight away.
154
220
  const cliFlags = { ...flags };
155
221
  delete cliFlags.join;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.9.5",
3
+ "version": "0.10.1",
4
4
  "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,5 +32,8 @@
32
32
  "cursor",
33
33
  "coding-agent"
34
34
  ],
35
- "license": "MIT"
35
+ "license": "MIT",
36
+ "dependencies": {
37
+ "agent-browser": "^0.35.0"
38
+ }
36
39
  }
package/src/cli.mjs CHANGED
@@ -332,6 +332,13 @@ function runCliOnce(opts) {
332
332
  // inside runCli (not a wrapper) so every call site, present and future, gets it.
333
333
  const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
334
334
 
335
+ // Compat retry (0974): --auto-review is the current Cursor CLI's safe,
336
+ // server-classified approval mode, which lets read-only Web calls run in the
337
+ // conversational `ask` profile. Older Cursor CLIs do not know that flag. On
338
+ // the exact commander error, retry without it so upgrading Hilos cannot make
339
+ // an otherwise-working older local CLI fail before it sees the prompt.
340
+ const UNKNOWN_CURSOR_AUTO_REVIEW_RE = /unknown option '--auto-review'/;
341
+
335
342
  // Compat retry (0777): the permission gate adds `--permission-prompt-tool` (and
336
343
  // the `--mcp-config` that serves it) to claude runs. The flag is real but
337
344
  // undocumented on 2.1.233, so a Claude Code old enough not to know it would
@@ -373,7 +380,28 @@ export async function runCli(opts) {
373
380
  args.includes("--trust") &&
374
381
  UNKNOWN_TRUST_RE.test(first.stderr || "")
375
382
  ) {
376
- return runCliOnce({ ...opts, args: args.filter((a) => a !== "--trust") });
383
+ const withoutTrust = args.filter((a) => a !== "--trust");
384
+ const retried = await runCliOnce({ ...opts, args: withoutTrust });
385
+ if (
386
+ retried.status !== 0 &&
387
+ !retried.aborted &&
388
+ withoutTrust.includes("--auto-review") &&
389
+ UNKNOWN_CURSOR_AUTO_REVIEW_RE.test(retried.stderr || "")
390
+ ) {
391
+ return runCliOnce({
392
+ ...opts,
393
+ args: withoutTrust.filter((a) => a !== "--auto-review"),
394
+ });
395
+ }
396
+ return retried;
397
+ }
398
+ if (
399
+ first.status !== 0 &&
400
+ !first.aborted &&
401
+ args.includes("--auto-review") &&
402
+ UNKNOWN_CURSOR_AUTO_REVIEW_RE.test(first.stderr || "")
403
+ ) {
404
+ return runCliOnce({ ...opts, args: args.filter((a) => a !== "--auto-review") });
377
405
  }
378
406
  if (
379
407
  first.status !== 0 &&
@@ -149,6 +149,7 @@ export function mapCodexDecision(reply, availableDecisions) {
149
149
  * prompt?: string,
150
150
  * env?: Record<string, string | undefined>,
151
151
  * model?: string | null,
152
+ * webSearch?: boolean,
152
153
  * sandbox?: string,
153
154
  * approvalPolicy?: string,
154
155
  * resumeThreadId?: string | null,
@@ -173,6 +174,7 @@ export async function runCodexMcpSession({
173
174
  prompt = "",
174
175
  env,
175
176
  model = null,
177
+ webSearch = true,
176
178
  sandbox = "workspace-write",
177
179
  approvalPolicy = "untrusted",
178
180
  resumeThreadId = null,
@@ -464,7 +466,7 @@ export async function runCodexMcpSession({
464
466
  // The gated transport cannot inherit `codex exec` argv. Carry the
465
467
  // same public web-search capability through the MCP tool's config;
466
468
  // permission policy and sandboxing remain unchanged.
467
- config: { tools: { web_search: true } },
469
+ ...(webSearch ? { config: { tools: { web_search: true } } } : {}),
468
470
  ...(model ? { model } : {}),
469
471
  },
470
472
  };
package/src/config.mjs CHANGED
@@ -53,6 +53,16 @@ const DEFAULTS = {
53
53
  // and preview (default) vs production. Shape:
54
54
  // { "<channelId>": { provider: "vercel" | "netlify", prod: false } }.
55
55
  deploy: {},
56
+ // Local WebMCP consumer (0959). Disabled until the person names exact site
57
+ // origins and exact read-only tools. A site's readOnlyHint is never an
58
+ // authorization signal. Shape:
59
+ // { origins: { "https://example.com": { readTools: ["search_docs"] } },
60
+ // profile?: "/absolute/private/browser/profile" }
61
+ webMcp: null,
62
+ // Public web is a baseline agent capability. Supported local CLIs use their
63
+ // own search/fetch tools; false stops Hilos from enabling or requesting it.
64
+ // The CLI's own global/project policy remains the ultimate authority.
65
+ webSearch: true,
56
66
  // acceptEdits lets the CLI make file edits without prompting (bias to action);
57
67
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
58
68
  // want a stricter (or `--dangerously-skip-permissions`) command.
@@ -167,6 +177,12 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
167
177
  codingCmd: process.env.CODING_CMD,
168
178
  codingModel: process.env.HILOS_CODING_MODEL,
169
179
  chatCmd: process.env.HILOS_CHAT_CMD,
180
+ webSearch:
181
+ /^(1|on|true)$/i.test(process.env.HILOS_WEB_SEARCH || "")
182
+ ? true
183
+ : /^(0|off|false)$/i.test(process.env.HILOS_WEB_SEARCH || "")
184
+ ? false
185
+ : undefined,
170
186
  heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
171
187
  progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
172
188
  chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
@@ -251,6 +267,8 @@ const LIVE_FIELDS = [
251
267
  // like `repos`, so a partial edit doesn't drop the defaults.
252
268
  "folders",
253
269
  "deploy",
270
+ "webMcp",
271
+ "webSearch",
254
272
  ];
255
273
 
256
274
  /**
@@ -293,6 +311,14 @@ export function reloadConfig(prev) {
293
311
  // the normal on state. An explicit launch flag remains pinned when the file
294
312
  // was never changed, preserving the 0576 precedence rule above.
295
313
  if (changed("replyBridge")) next.replyBridge = file.replyBridge !== false;
314
+ // WebMCP is an authorization allowlist. Removing it must revoke the browser
315
+ // bridge on the next poll rather than preserving stale origins/tools until a
316
+ // restart. An explicit null and a deleted edited key both mean disabled.
317
+ if (changed("webMcp")) next.webMcp = file.webMcp ?? null;
318
+ // Native web is safe-on by default. Removing an edited `webSearch:false`
319
+ // returns to that default on the next poll instead of pinning a stale opt-out
320
+ // until restart; the environment override below still wins.
321
+ if (changed("webSearch")) next.webSearch = file.webSearch !== false;
296
322
  // The environment stays the operator's override on reload, in both
297
323
  // directions — a machine that opted out with =0 must not be opted back in by
298
324
  // a file edit (0792).
@@ -303,6 +329,8 @@ export function reloadConfig(prev) {
303
329
  if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
304
330
  if (process.env.HILOS_CODING_MODEL) next.codingModel = process.env.HILOS_CODING_MODEL;
305
331
  if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
332
+ if (/^(1|on|true)$/i.test(process.env.HILOS_WEB_SEARCH || "")) next.webSearch = true;
333
+ if (/^(0|off|false)$/i.test(process.env.HILOS_WEB_SEARCH || "")) next.webSearch = false;
306
334
  if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
307
335
  if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
308
336
  if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
@@ -327,6 +355,7 @@ export function writeStarterConfig(path, partial = {}) {
327
355
  repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
328
356
  codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
329
357
  ...(partial.codingModel ? { codingModel: partial.codingModel } : {}),
358
+ webSearch: partial.webSearch !== false,
330
359
  defaultBranch: DEFAULTS.defaultBranch,
331
360
  // false = open a PR directly (bias to action); true = approve-before-push.
332
361
  gate: false,
package/src/handler.mjs CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  import { makeStreamParser, createUsageFold } from "./agent-events.mjs";
41
41
  import {
42
42
  detectVendor,
43
+ chatWebArgs,
43
44
  codeStreamArgs,
44
45
  codeWebArgs,
45
46
  codeDirArgs,
@@ -49,6 +50,8 @@ import {
49
50
  attachTarget,
50
51
  createProgressEmitter,
51
52
  fastChatCmd,
53
+ nativeWebPrompt,
54
+ webToolEnv,
52
55
  } from "./progress-emitter.mjs";
53
56
  import { createTranscriptTap } from "./transcript.mjs";
54
57
  import { imagePromptNote, renderAttachmentLine } from "./attachments.mjs";
@@ -83,6 +86,7 @@ import {
83
86
  shouldGateCodexPermissions,
84
87
  } from "./codex-mcp-session.mjs";
85
88
  import { createUngatedRunNotice } from "./permission-gate.mjs";
89
+ import { webMcpAgentPrompt } from "./webmcp-bridge.mjs";
86
90
 
87
91
  /**
88
92
  * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
@@ -97,6 +101,15 @@ function codingChildEnv(cfg) {
97
101
  : undefined;
98
102
  }
99
103
 
104
+ /** Add only the selected CLI's public-web enablement to its already-isolated
105
+ * child environment. Today this changes OpenCode only; keeping it shared makes
106
+ * argv, ACP, HTTP, gated, and conversational runs follow one contract. */
107
+ function codingChildWebEnv(cfg, vendor) {
108
+ return webToolEnv(vendor, codingChildEnv(cfg), {
109
+ enabled: cfg?.webSearch !== false,
110
+ });
111
+ }
112
+
100
113
  /**
101
114
  * The fast chat command for this config: an explicit chatCmd wins, else the
102
115
  * coding vendor's verified non-interactive print mode (fastChatCmd), else the
@@ -693,6 +706,7 @@ async function runCodexGatedSession({
693
706
  // took the account default before this, so a preset was silently ignored
694
707
  // exactly where the operator was most likely to have set one.
695
708
  model: model || null,
709
+ webSearch: cfg.webSearch !== false,
696
710
  resumeThreadId,
697
711
  timeoutMs: cfg.runTimeoutMs,
698
712
  signal,
@@ -1263,6 +1277,15 @@ function memoryPreamble(workspaceMemory) {
1263
1277
  return m ? `Workspace context (the project's soul):\n${m}\n\n` : "";
1264
1278
  }
1265
1279
 
1280
+ /** Shared context for a model that may act, not the read-only intent router. */
1281
+ function agentPreamble(workspaceMemory, cfg) {
1282
+ return (
1283
+ memoryPreamble(workspaceMemory) +
1284
+ nativeWebPrompt(cfg?.webSearch !== false) +
1285
+ webMcpAgentPrompt(cfg)
1286
+ );
1287
+ }
1288
+
1266
1289
  /**
1267
1290
  * Recent conversation the agent should reply within: the thread it was pinged in
1268
1291
  * (threads are where conversations live), else the channel tail. Returns the raw
@@ -1368,7 +1391,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1368
1391
  `concisely and directly as a single chat message — no preamble, no headings. ` +
1369
1392
  `${repoLine}` +
1370
1393
  `${dmJudgmentBlock(implicitDm)}\n\n` +
1371
- `${memoryPreamble(workspaceMemory)}` +
1394
+ `${agentPreamble(workspaceMemory, cfg)}` +
1372
1395
  `Conversation so far:\n${transcript}`;
1373
1396
 
1374
1397
  // Chat uses the FAST one-shot command (the coding vendor's own print mode when
@@ -1376,6 +1399,10 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1376
1399
  // and a stalled model can't dead-air the channel for the full coding timeout.
1377
1400
  const cmd = chatCmdFor(cfg);
1378
1401
  const parts = cmd.split(" ").filter(Boolean);
1402
+ const chatVendor = detectVendor(cmd);
1403
+ const webArgs = chatWebArgs(chatVendor, parts.slice(1), {
1404
+ enabled: cfg.webSearch !== false,
1405
+ });
1379
1406
  console.log(` chat → running \`${cmd}\` (output appears when it finishes)…`);
1380
1407
 
1381
1408
  // If the reply is slow, post ONE "still thinking…" ping and then edit it into
@@ -1406,11 +1433,11 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1406
1433
  try {
1407
1434
  run = await runCli({
1408
1435
  cmd: parts[0],
1409
- args: [...parts.slice(1), prompt],
1436
+ args: [...parts.slice(1), ...webArgs, prompt],
1410
1437
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
1411
1438
  label: "thinking",
1412
1439
  signal,
1413
- env: codingChildEnv(cfg),
1440
+ env: codingChildWebEnv(cfg, chatVendor),
1414
1441
  });
1415
1442
  } finally {
1416
1443
  beatStopped = true;
@@ -2090,7 +2117,9 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2090
2117
  // 0779: [] for every vendor without a verified image flag — their argv is
2091
2118
  // byte-identical to before, and the prompt note still names the files.
2092
2119
  const imageArgs = codeImageArgs(vendor, localImages);
2093
- const webArgs = codeWebArgs(vendor, parts.slice(1));
2120
+ const webArgs = codeWebArgs(vendor, parts.slice(1), {
2121
+ enabled: cfg.webSearch !== false,
2122
+ });
2094
2123
  const codeArgs = [
2095
2124
  ...parts.slice(1),
2096
2125
  ...webArgs,
@@ -2175,10 +2204,10 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2175
2204
  cmd: parts[0],
2176
2205
  vendor,
2177
2206
  cwd: folderPath,
2178
- prompt: memoryPreamble(workspaceMemory) + promptText,
2207
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
2179
2208
  timeoutMs: cfg.runTimeoutMs,
2180
2209
  signal,
2181
- env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
2210
+ env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
2182
2211
  onData: handleCliData,
2183
2212
  onEvent: handleCliEvent,
2184
2213
  ...openCodePermissionCallbacks({
@@ -2194,10 +2223,10 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2194
2223
  cmd: parts[0],
2195
2224
  args: codeArgs,
2196
2225
  cwd: folderPath,
2197
- prompt: memoryPreamble(workspaceMemory) + promptText,
2226
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
2198
2227
  timeoutMs: cfg.runTimeoutMs,
2199
2228
  signal,
2200
- env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
2229
+ env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
2201
2230
  onData: handleCliData,
2202
2231
  ...openCodePermissionCallbacks({
2203
2232
  tool,
@@ -2214,7 +2243,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2214
2243
  cmd: parts[0],
2215
2244
  codeArgs,
2216
2245
  cwd: folderPath,
2217
- prompt: memoryPreamble(workspaceMemory) + promptText,
2246
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
2218
2247
  model: resolvedModelId,
2219
2248
  signal,
2220
2249
  onData: handleCliData,
@@ -2234,7 +2263,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2234
2263
  onGateDropped: () => noticeUngatedRun(parts[0]),
2235
2264
  cmd: parts[0],
2236
2265
  codeArgs,
2237
- prompt: memoryPreamble(workspaceMemory) + promptText,
2266
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
2238
2267
  cwd: folderPath,
2239
2268
  signal,
2240
2269
  onData: handleCliData,
@@ -2252,13 +2281,13 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2252
2281
  cmd: parts[0],
2253
2282
  args: [
2254
2283
  ...codeArgs,
2255
- memoryPreamble(workspaceMemory) + promptText,
2284
+ agentPreamble(workspaceMemory, cfg) + promptText,
2256
2285
  ],
2257
2286
  cwd: folderPath,
2258
2287
  timeoutMs: cfg.runTimeoutMs,
2259
2288
  label: "coding",
2260
2289
  signal,
2261
- env: codingChildEnv(cfg),
2290
+ env: codingChildWebEnv(cfg, vendor),
2262
2291
  onData: handleCliData,
2263
2292
  });
2264
2293
  }
@@ -3431,7 +3460,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3431
3460
  // `exec`, so it has to sit ahead of it. [] for every vendor without a
3432
3461
  // verified flag, leaving their argv byte-identical to before.
3433
3462
  const imageArgs = codeImageArgs(vendor, localImages);
3434
- const webArgs = codeWebArgs(vendor, parts.slice(1));
3463
+ const webArgs = codeWebArgs(vendor, parts.slice(1), {
3464
+ enabled: cfg.webSearch !== false,
3465
+ });
3435
3466
  const codeArgs = streamOn
3436
3467
  ? [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
3437
3468
  : [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
@@ -3526,13 +3557,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3526
3557
  cmd: parts[0],
3527
3558
  vendor,
3528
3559
  cwd: repoPath,
3529
- prompt: memoryPreamble(workspaceMemory) + promptText,
3560
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
3530
3561
  // 0778: approvals AND continuity. `resume:false` (the never-worse
3531
3562
  // retry) drops it exactly like buildResumeArgs does.
3532
3563
  resumeSessionId: resume ? resumeSessionId : null,
3533
3564
  timeoutMs: cfg.runTimeoutMs,
3534
3565
  signal,
3535
- env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
3566
+ env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
3536
3567
  onData: handleCliData,
3537
3568
  onEvent: handleCliEvent,
3538
3569
  ...openCodePermissionCallbacks({
@@ -3549,13 +3580,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3549
3580
  cmd: parts[0],
3550
3581
  args: codeArgs,
3551
3582
  cwd: repoPath,
3552
- prompt: memoryPreamble(workspaceMemory) + promptText,
3583
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
3553
3584
  timeoutMs: cfg.runTimeoutMs,
3554
3585
  signal,
3555
3586
  // A model running inside the server must never inherit the daemon's
3556
3587
  // hilos bearer token. The bridge's random loopback password is added
3557
3588
  // after this scrub and dies with the process group.
3558
- env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
3589
+ env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
3559
3590
  onData: handleCliData,
3560
3591
  ...openCodePermissionCallbacks({
3561
3592
  tool,
@@ -3573,7 +3604,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3573
3604
  cmd: parts[0],
3574
3605
  codeArgs,
3575
3606
  cwd: repoPath,
3576
- prompt: memoryPreamble(workspaceMemory) + promptText,
3607
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
3577
3608
  model: resolvedModelId,
3578
3609
  // Codex's own resume over this transport. `resume:false` (the
3579
3610
  // never-worse retry) drops it exactly like buildResumeArgs does.
@@ -3599,7 +3630,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3599
3630
  // codeArgs already carries this run's resume flags, so a gated
3600
3631
  // iterate resumes AND raises cards — the two never traded off.
3601
3632
  codeArgs,
3602
- prompt: memoryPreamble(workspaceMemory) + promptText,
3633
+ prompt: agentPreamble(workspaceMemory, cfg) + promptText,
3603
3634
  cwd: repoPath,
3604
3635
  signal,
3605
3636
  onData: handleCliData,
@@ -3621,12 +3652,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3621
3652
  // why nothing above the unit tests could ever drive it (0787).
3622
3653
  run = await deps.runCli({
3623
3654
  cmd: parts[0],
3624
- args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
3655
+ args: [...codeArgs, agentPreamble(workspaceMemory, cfg) + promptText],
3625
3656
  cwd: repoPath,
3626
3657
  timeoutMs: cfg.runTimeoutMs,
3627
3658
  label: "coding",
3628
3659
  signal,
3629
- env: codingChildEnv(cfg),
3660
+ env: codingChildWebEnv(cfg, vendor),
3630
3661
  onData: handleCliData,
3631
3662
  });
3632
3663
  }
@@ -23,6 +23,13 @@ import {
23
23
  sanitizeText,
24
24
  } from "./agent-events.mjs";
25
25
  import { commandArgv } from "./argv.mjs";
26
+ export {
27
+ chatWebArgs,
28
+ codeWebArgs,
29
+ nativeWebPrompt,
30
+ webCapability,
31
+ webToolEnv,
32
+ } from "./web-tools.mjs";
26
33
 
27
34
  /**
28
35
  * Which parser/stream-flags a coding command wants, from its FIRST token.
@@ -57,8 +64,9 @@ export function detectVendor(codingCmd) {
57
64
  * Code installed just to answer chat (0521). Every command is the vendor's
58
65
  * verified non-interactive print mode; the daemon appends the prompt as the last
59
66
  * arg. codex carries --skip-git-repo-check because chat (and the read-only
60
- * review sandbox) can run outside a git checkout, plus the explicit web-search
61
- * config because `codex exec` otherwise leaves that tool off. cursor carries
67
+ * review sandbox) can run outside a git checkout; the responder adds its
68
+ * read-only/web profile separately so this same command stays cheap for the
69
+ * no-tools classifier and plan acknowledgement. cursor carries
62
70
  * --output-format text (explicit, so a CLI default change can never post raw
63
71
  * JSONL into the channel) and --trust (its Jan-2026 workspace-trust gate fails
64
72
  * headless runs at spawn in untrusted directories — 0572; pre-2026 CLIs reject
@@ -71,13 +79,14 @@ export function detectVendor(codingCmd) {
71
79
  */
72
80
  export function fastChatCmd(vendor) {
73
81
  if (vendor === "claude_code") return "claude -p --model haiku";
74
- if (vendor === "codex") {
75
- return "codex exec --skip-git-repo-check -c tools.web_search=true";
76
- }
82
+ if (vendor === "codex") return "codex exec --skip-git-repo-check";
77
83
  if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
78
84
  if (vendor === "opencode") return "opencode run";
79
- if (vendor === "antigravity") return "agy -p";
80
- if (vendor === "hermes") return "hermes -z";
85
+ // Antigravity's -p consumes the NEXT argv as its prompt, so every option must
86
+ // sit before it. Hermes's -q has the same shape. Keep their complete generated
87
+ // read-only/web profile in the base command rather than appending after it.
88
+ if (vendor === "antigravity") return "agy --mode plan -p";
89
+ if (vendor === "hermes") return "hermes chat --toolsets safe,web -q";
81
90
  return "";
82
91
  }
83
92
 
@@ -110,31 +119,6 @@ export function codeStreamArgs(vendor) {
110
119
  return [];
111
120
  }
112
121
 
113
- /**
114
- * Make Codex's built-in public web search available to code runs. This is a
115
- * capability flag, not an instruction to browse; Codex decides whether the
116
- * task needs it. An operator's explicit true/false override wins unchanged.
117
- * Other vendors already expose their own web tools and receive no guessed
118
- * flags.
119
- * @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
120
- * @param {string[]} baseArgs
121
- * @returns {string[]}
122
- */
123
- export function codeWebArgs(vendor, baseArgs = []) {
124
- if (vendor !== "codex") return [];
125
- const hasOverride = baseArgs.some((arg, index) => {
126
- if (/^tools\.web_search=/.test(arg)) return true;
127
- if (
128
- (baseArgs[index - 1] === "-c" || baseArgs[index - 1] === "--config") &&
129
- /^tools\.web_search=/.test(arg)
130
- ) {
131
- return true;
132
- }
133
- return /^--config=tools\.web_search=/.test(arg);
134
- });
135
- return hasOverride ? [] : ["-c", "tools.web_search=true"];
136
- }
137
-
138
122
  /**
139
123
  * Extra args that hand the code run an IMAGE, appended to the code run's argv
140
124
  * (0779). Verified against the installed binaries, per the 0521 rule — never