@robzilla1738/agentswarm 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +29 -12
  2. package/dist/agent.js +6 -15
  3. package/dist/cli.js +31 -4
  4. package/dist/config.js +44 -1
  5. package/dist/crawltools.js +3 -22
  6. package/dist/executor.js +276 -60
  7. package/dist/hub.js +67 -3
  8. package/dist/journal.js +39 -5
  9. package/dist/memory.js +17 -11
  10. package/dist/pdftext.js +211 -0
  11. package/dist/prompts.js +23 -15
  12. package/dist/report.js +39 -1
  13. package/dist/run.js +8 -0
  14. package/dist/sandbox.js +11 -0
  15. package/dist/searchcore.js +55 -2
  16. package/dist/state.js +67 -17
  17. package/dist/tools.js +208 -19
  18. package/dist/util.js +117 -3
  19. package/dist/webtools.js +185 -32
  20. package/package.json +1 -1
  21. package/ui/out/404/index.html +1 -1
  22. package/ui/out/404.html +1 -1
  23. package/ui/out/_next/static/chunks/677-a62d486d6734bcf3.js +1 -0
  24. package/ui/out/_next/static/chunks/app/run/page-c29f95c51af08c60.js +1 -0
  25. package/ui/out/_next/static/chunks/app/settings/page-41a5d8ba43ecfd4a.js +1 -0
  26. package/ui/out/_next/static/css/{9f7bd82b8e4c762c.css → d95c2ba395730031.css} +1 -1
  27. package/ui/out/index.html +1 -1
  28. package/ui/out/index.txt +3 -3
  29. package/ui/out/run/index.html +1 -1
  30. package/ui/out/run/index.txt +3 -3
  31. package/ui/out/settings/index.html +1 -1
  32. package/ui/out/settings/index.txt +3 -3
  33. package/ui/out/_next/static/chunks/677-859e8d42add1806b.js +0 -1
  34. package/ui/out/_next/static/chunks/app/run/page-2420c9e4c963d9b3.js +0 -1
  35. package/ui/out/_next/static/chunks/app/settings/page-092a6bf42dfde57d.js +0 -1
  36. /package/ui/out/_next/static/{errjtBR_bKoee8ogLp8xk → JFkx5KtNi0DYyqm_THzbY}/_buildManifest.js +0 -0
  37. /package/ui/out/_next/static/{errjtBR_bKoee8ogLp8xk → JFkx5KtNi0DYyqm_THzbY}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -95,25 +95,32 @@ swarm run "Research the best open-source vector DBs in 2026 and write a recommen
95
95
  | `swarm cancel <id>` | Stop a run. It still synthesizes a report from completed work. |
96
96
  | `swarm config [list\|get\|set …]` | Manage `~/.agentswarm/config.json`. |
97
97
  | `swarm models` | List models from the active provider. |
98
+ | `swarm config unset <key>` | Remove a setting (e.g., `swarm config unset firecrawlApiKey`). |
98
99
  | `swarm demo` | Run a self-contained demo mission in an isolated workspace. |
99
100
 
100
101
  Run options (also on the UI launch form under Options): `--workers N` (parallelism), `--tasks N`, `--steps N` (tool steps per task), `--budget N` (token cap), `--model`, `--conductor`, `--verify off|normal|strict`, `--effort low|medium|high|max`, `--no-thinking`, `--sandbox host|docker|e2b|modal|vercel|auto` (shell runtime for this run), `--cwd <path>` (run against a real directory instead of an isolated workspace), `--fg` (foreground in this process).
101
102
 
102
103
  ## How it works
103
104
 
104
- The conductor is a model with six tools: `spawn_tasks`, `set_phase`, `update_plan`, `read_report`, `wait`, and `finish`. It reads the mission, spawns self-contained tasks (each with an objective, success criteria, a role, optional dependencies, and an optional `verify` flag), then reacts as reports come back. On long missions it declares phases (`set_phase`) whose goals and exit criteria are pinned into every update — so the plan survives even when old history is trimmed and replaced by a mission ledger (settled tasks, decisions, current phase).
105
+ The conductor is a model with six tools: `spawn_tasks`, `set_phase`, `update_plan`, `read_report`, `wait`, and `finish`. It reads the mission, spawns self-contained tasks (each with an objective, success criteria, a role, optional dependencies, and an optional `verify` flag), then reacts as reports come back. On long missions it declares phases (`set_phase`) whose goals and exit criteria are pinned into every update — so the plan survives even when old history is trimmed and replaced by a mission ledger (settled tasks, decisions, current phase). On resume, the conductor is re-seeded with this ledger so it picks up where it left off without losing context.
105
106
 
106
- Each task becomes an autonomous agent with a tool budget. It works in small steps, posts durable findings to the blackboard (decisions are never trimmed from digests; `search_notes` searches the full history), journals progress checkpoints on long tasks, saves artifacts, and ends by reporting back with structured handoff fields (`key_facts`, `open_questions`, `files_touched`). Dependent tasks receive report excerpts plus those fields, and can pull full text with `read_report`.
107
+ Each task becomes an autonomous agent with a tool budget. It works in small steps, posts durable findings to the blackboard (decisions are never trimmed from digests; `search_notes` now supports `kind` filters to find decisions, context, or source links without noise), journals progress checkpoints on long tasks, saves artifacts, and ends by reporting back with structured handoff fields (`key_facts`, `open_questions`, `files_touched`) plus any sources discovered. Sources flow through to the final report as numbered citations — every source is deduplicated, attributed, and linked inline (`[1]`) with a full bibliography at the end. Dependent tasks receive report excerpts plus those fields, and can pull full text with `read_report`.
107
108
 
108
- **Scale.** A global AIMD limiter (`maxConcurrentCalls`) bounds concurrent model calls per endpoint — a 429 halves the ceiling, successes recover it, and conductor calls always jump the queue, so a 100-agent swarm degrades gracefully instead of melting down. Settles are debounced before waking the conductor; on big runs the task table collapses settled waves (failures stay itemized) and excess reports become one-liners the conductor can expand with `read_report`. Spawn specs take a `model` tier (`cheap` for scouts, `strong` for leads/verifiers via `cheapModel`/`strongModel` config) and `team:true` to run a task as a full sub-swarm its own conductor decomposes it in parallel and reports one consolidated result, with all activity journaled under its `teamId`.
109
+ **Search & research.** Web search now includes engine rate-limit cooldowns (on a 429, the engine skips it for a while and re-plans); queries reformulate themselves down to keywords if they get zero results (lifting recall without noise); results are freshness-ranked so recent content bubbles up. For academic queries, agents can use `academic_search` to query arXiv and Crossref directly no API key needed. Fetches from the web pull plain text via `fetch_url`, which extracts text from PDFs (zero runtime dependencies, zlib only), decodes non-UTF-8 charsets, and flags paywall shells so agents know when they hit a wall.
109
110
 
110
- **Long horizon.** The conductor maintains a living `mission-plan.md` (`update_plan`) pinned into every update and restored on resume; every 25 settled tasks a progress snapshot lands in `artifacts/` so multi-day runs always have a partial deliverable; and real-directory runs leave a memory (`~/.agentswarm/memory/`) of missions, outcomes, and decisions that seeds the next swarm in the same workspace.
111
+ **Scale.** A global AIMD limiter (`maxConcurrentCalls`) bounds concurrent model calls per endpoint a 429 halves the ceiling, successes recover it, and conductor calls always jump the queue, so a 100-agent swarm degrades gracefully instead of melting down. Settles are debounced before waking the conductor; on big runs the task table collapses settled waves (failures stay itemized) and excess reports become one-liners the conductor can expand with `read_report`. Spawn specs take a `model` tier (`cheap` for scouts, `strong` for leads/verifiers via `cheapModel`/`strongModel` config) and `team:true` to run a task as a full sub-swarm — its own conductor decomposes it in parallel and reports one consolidated result, with all activity journaled under its `teamId`. Context windows are configurable per model via `contextWindows` config; the engine respects each model's actual limit and compacts agent context accordingly.
111
112
 
112
- Verified tasks pass two gates: a free mechanical check (claimed artifacts must exist and be non-empty), then a blind LLM verifier that judges the deliverables against the objective with its own toolsit never sees the worker's blackboard. In `--verify strict` mode, a completeness critic reviews the whole run for gaps before synthesis (the conductor gets one round to fill them), and the final report is checked for faithfulness against the task reports.
113
+ **Worker tools.** The toolbelt gained `grep_files` for structured content search and `replace_in_file` with atomic multi-edit batchesboth portable across sandboxes (Docker, E2B, Modal, Vercel).
114
+
115
+ **Verification & quality.** Tasks pass a mechanical format pre-check (JSON/CSV/HTML structure), then a blind LLM verifier with its own tools. Failed verifications retry with structured feedback (problem/evidence/fix). The verifier gets copies of all dependencies' reports for context. In `--verify strict` mode, the verifier must back verdicts with tool-gathered evidence (not just a pass statement), a completeness critic reviews the whole run for gaps before synthesis, and the final report is checked for faithfulness against the task reports.
116
+
117
+ **Long horizon.** The conductor maintains a living `mission-plan.md` (`update_plan`) pinned into every update and restored on resume; every 25 settled tasks a progress snapshot lands in `artifacts/` so multi-day runs always have a partial deliverable; and real-directory runs leave a memory (`~/.agentswarm/memory/`) of missions, outcomes, and decisions that seeds the next swarm in the same workspace. When tasks fail, the cascade carries the root cause transitively — blocked tasks know why rather than just "dependency did not complete". Failed tasks surface their last failing tool call as diagnostics.
118
+
119
+ **Planning & steering.** The UI now includes a Plan tab showing the living `mission-plan.md`, and the conductor can update it from an agent note (`swarm note <id> "update the plan: ..."`). The budget sparkline in the run dashboard shows at-a-glance how much token budget remains.
113
120
 
114
121
  The scheduler starts a task as soon as its dependencies are done, up to the parallelism cap. Tasks whose dependencies failed are blocked and surfaced to the conductor for re-planning.
115
122
 
116
- When the conductor finishes (or the budget forces it), a synthesizer composes the final deliverable from every task report. Deliverables ship in the format the mission calls for — code, `.csv`/`.json` data, styled documents — alongside `final-report.md` and a self-contained `final-report.html` rendering (open it with `swarm report <id> --open`).
123
+ When the conductor finishes (or the budget forces it), a synthesizer composes the final deliverable from every task report. Deliverables ship in the format the mission calls for — code, `.csv`/`.json` data, styled documents — alongside `final-report.md` and a self-contained `final-report.html` rendering (open it with `swarm report <id> --open`). The final report includes an inline-cited Sources section and all findings are preserved.
117
124
 
118
125
  The journal is the source of truth. Every run is an append-only `events.jsonl`; the terminal dashboard, the web UI, and `swarm ls` all reduce the same file. That's why runs survive crashes and can be resumed or replayed. Runs live under `~/.agentswarm/runs/<id>/`.
119
126
 
@@ -137,15 +144,24 @@ src/ TypeScript engine (zero runtime deps)
137
144
  sandbox.ts sandbox runtimes: host, docker, E2B, Modal, Vercel
138
145
  agent.ts the agent loop: stream → tool calls → results → repeat, with compaction
139
146
  executor.ts the orchestrator: conductor loop, parallel scheduler, verify, synth, budget
140
- tools.ts worker toolbelt (shell, files, web, blackboard, artifacts) + safety
141
- webtools.ts web search/fetch: SearchKit → TinyFish → DuckDuckGo fallback chain
147
+ tools.ts worker toolbelt (shell, files, web, blackboard, artifacts) + safety + grep/replace
148
+ webtools.ts web search/fetch: SearchKit → TinyFish → DuckDuckGo fallback chain, with cooldowns + reformulation
149
+ searchcore.ts search ranking (freshness boost, academic intent detection) + academic_search (arXiv/Crossref)
150
+ pdftext.ts PDF text extraction (zero deps, zlib only)
151
+ crawltools.ts crawl backend resolver (firecrawl/context.dev/deepcrawl)
142
152
  journal.ts append-only crash-safe event log (single source of truth)
143
- state.ts pure reducer: events → live run state
144
- hub.ts localhost HTTP API + SSE + static UI server
153
+ state.ts pure reducer: events → live run state (with budgetSeries sampling)
154
+ hub.ts localhost HTTP API + SSE + static UI server (CORS locked to localhost)
145
155
  terminal.ts live TTY dashboard
146
156
  cli.ts command-line interface
157
+ memory.ts atomic runId-keyed cross-run memory + interim snapshots
147
158
  ui/ Next.js 15 + Tailwind 4 web app (static-exported, served by the hub)
159
+ components/SideRail Plan tab showing mission-plan.md
160
+ app/run/page.tsx Blackboard search with kind filters + budget sparkline
161
+ app/settings/page.tsx Test buttons for crawl/search backends, key management
148
162
  test/ end-to-end test with a scripted mock model (no API key needed)
163
+ e2e.js 21 phases covering the full pipeline, including citations + force + resume + budget + verify + teams
164
+ unit/*.test.js individual suites for tools, crawl, memory, pdftext, webtools, searchcore, citations
149
165
  ```
150
166
 
151
167
  ## Testing
@@ -158,11 +174,12 @@ Boots a mock model server and drives real missions through the engine, offline,
158
174
 
159
175
  ## Safety notes
160
176
 
161
- - Safe mode is on by default. It blocks obviously destructive shell commands and confines writes to the working directory. `--no-safe` turns it off for a run; only do that when you trust the mission.
177
+ - Safe mode is on by default. It blocks obviously destructive shell commands and confines writes to the working directory, plus symlink escapes to parent directories. `--no-safe` turns it off for a run; only do that when you trust the mission.
178
+ - The hub API (started by `swarm serve`) only accepts requests from localhost origins (`http://localhost:*` and `127.0.0.1:*`). The web UI runs in your browser locally and never phones home.
162
179
  - Runs default to an isolated per-run workspace on this machine. That's a private directory, not a container. Agents still execute with your user's permissions; the engine strips API keys and sandbox credentials from their environment, and safe mode constrains commands and writes. For untrusted or risky missions, use `--sandbox docker` or a cloud runtime.
163
180
  - Use `--cwd <path>` (or Workspace → "A directory on disk" in the UI) to let agents touch a real project. Those runs always execute on the host, since touching your real files is the point.
164
181
  - Costs are estimates based on list prices and the token counts the API reports. Models without pricing data show $0. Set a `--budget` either way.
165
- - Keys are stored in `~/.agentswarm/config.json` (chmod 600) and are only sent to the APIs you configured.
182
+ - Keys are stored in `~/.agentswarm/config.json` (chmod 600) and are only sent to the APIs you configured. Use `swarm config unset <key>` to remove a key, or the Settings UI for test buttons on crawl/search backends.
166
183
 
167
184
  ## Author
168
185
 
package/dist/agent.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runAgent = runAgent;
4
4
  exports.estimateMessages = estimateMessages;
5
+ const config_1 = require("./config");
5
6
  const deepseek_1 = require("./deepseek");
6
7
  const prompts_1 = require("./prompts");
7
8
  const types_1 = require("./types");
@@ -58,20 +59,10 @@ async function runAgent(p) {
58
59
  if (stopReason)
59
60
  break;
60
61
  steps++;
61
- let res;
62
- try {
63
- res = await callModel();
64
- }
65
- catch (e) {
66
- // The chat client already retries 429/5xx; this catches the rest of the
67
- // transient class (connection resets, DNS blips) once per step so a
68
- // single network hiccup doesn't burn a whole task attempt.
69
- if (p.signal.aborted)
70
- throw e;
71
- hooks.onLog?.("warn", `${p.agentId}: model call failed (${(0, util_1.errMsg)(e)}); retrying once`);
72
- await new Promise((r) => setTimeout(r, 1500));
73
- res = await callModel();
74
- }
62
+ // No retry here: the chat client already retries the whole transient
63
+ // class (429/5xx/network) with backoff; anything that escapes it is
64
+ // permanent (4xx) and re-sending only doubles the doomed spend.
65
+ const res = await callModel();
75
66
  hooks.onUsage?.(p.model, res.usage);
76
67
  usage = (0, types_1.addUsage)(usage, res.usage);
77
68
  if (res.toolCalls.length === 0) {
@@ -142,7 +133,7 @@ async function runAgent(p) {
142
133
  messages.push({ role: "tool", tool_call_id: call.id, content: result });
143
134
  }
144
135
  hooks.onTranscript?.(messages);
145
- if (estimateMessages(messages) > cfg.contextTokenLimit) {
136
+ if (estimateMessages(messages) > (0, config_1.contextLimitFor)(cfg, p.model)) {
146
137
  messages = await compact(p, messages);
147
138
  hooks.onTranscript?.(messages);
148
139
  hooks.onLog?.("info", `${p.agentId}: context compacted`);
package/dist/cli.js CHANGED
@@ -524,15 +524,27 @@ async function cmdConfig(rest, flags) {
524
524
  const cfg = (0, config_1.loadConfig)();
525
525
  if (sub === "get" && rest[1]) {
526
526
  const key = rest[1];
527
- const v = key === "apiKey" || key === "tinyfishApiKey" ? (0, config_1.maskKey)(String(cfg[key])) : cfg[key];
527
+ if (key === "providers") {
528
+ // Nested per-provider creds — mask every apiKey, never dump raw.
529
+ const masked = Object.fromEntries(Object.entries(cfg.providers ?? {}).map(([id, c]) => [
530
+ id,
531
+ { ...c, ...(c?.apiKey ? { apiKey: (0, config_1.maskKey)(c.apiKey) } : {}) },
532
+ ]));
533
+ console.log(JSON.stringify(masked, null, 2));
534
+ return;
535
+ }
536
+ const v = (0, config_1.isSecretConfigKey)(key) ? (0, config_1.maskKey)(String(cfg[key] ?? "")) : cfg[key];
528
537
  console.log(typeof v === "object" ? JSON.stringify(v, null, 2) : String(v));
529
538
  return;
530
539
  }
531
540
  console.log(util_1.ansi.bold("config") + util_1.ansi.gray(` (${(0, config_1.configPath)()})`));
532
541
  for (const k of config_1.SETTABLE_KEYS) {
533
542
  let v = cfg[k];
534
- if (k === "apiKey" || k === "tinyfishApiKey")
535
- v = v ? (0, config_1.maskKey)(String(v)) : util_1.ansi.red("(not set)");
543
+ // Every secret-bearing key prints masked `config list` output ends up
544
+ // in terminal scrollback and pasted bug reports.
545
+ if ((0, config_1.isSecretConfigKey)(k)) {
546
+ v = v ? (0, config_1.maskKey)(String(v)) : k === "apiKey" ? util_1.ansi.red("(not set)") : "(not set)";
547
+ }
536
548
  console.log(` ${k.padEnd(18)} ${util_1.ansi.gray(String(v))}`);
537
549
  }
538
550
  return;
@@ -559,11 +571,26 @@ async function cmdConfig(rest, flags) {
559
571
  console.log(util_1.ansi.gray(" verify it works: ") + "swarm models");
560
572
  return;
561
573
  }
574
+ if (sub === "unset") {
575
+ const key = rest[1];
576
+ if (!key)
577
+ throw new Error("usage: swarm config unset <key>");
578
+ if (!config_1.SETTABLE_KEYS.includes(key)) {
579
+ throw new Error(`unknown key. Keys: ${config_1.SETTABLE_KEYS.join(", ")}`);
580
+ }
581
+ // apiKey/baseUrl route into the active provider's creds, so clearing
582
+ // means writing "". Everything else is deleted from the file outright —
583
+ // the default applies again (unset model:"" would brick every run).
584
+ const cred = key === "apiKey" || key === "baseUrl";
585
+ (0, config_1.saveConfig)({ [key]: cred ? "" : undefined });
586
+ console.log(util_1.ansi.green("✓ ") + `cleared ${key}` + (cred ? "" : util_1.ansi.gray(" — default applies")));
587
+ return;
588
+ }
562
589
  if (sub === "path") {
563
590
  console.log((0, config_1.configPath)());
564
591
  return;
565
592
  }
566
- throw new Error("usage: swarm config [list|get <key>|set <key> <value>|path]");
593
+ throw new Error("usage: swarm config [list|get <key>|set <key> <value>|unset <key>|path]");
567
594
  }
568
595
  async function cmdModels() {
569
596
  const cfg = (0, config_1.loadConfig)();
package/dist/config.js CHANGED
@@ -33,13 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.SETTABLE_KEYS = exports.SECRET_ENV_KEYS = exports.DEFAULTS = exports.DEFAULT_PRICING = void 0;
36
+ exports.SETTABLE_KEYS = exports.SECRET_ENV_KEYS = exports.DEFAULTS = exports.DEFAULT_WINDOWS = exports.DEFAULT_PRICING = void 0;
37
+ exports.contextLimitFor = contextLimitFor;
37
38
  exports.home = home;
38
39
  exports.runsDir = runsDir;
39
40
  exports.runDir = runDir;
40
41
  exports.configPath = configPath;
41
42
  exports.loadConfig = loadConfig;
42
43
  exports.saveConfig = saveConfig;
44
+ exports.isSecretConfigKey = isSecretConfigKey;
43
45
  exports.maskKey = maskKey;
44
46
  exports.coerceConfigValue = coerceConfigValue;
45
47
  const fs = __importStar(require("fs"));
@@ -63,6 +65,20 @@ exports.DEFAULT_PRICING = {
63
65
  "MiniMax-M2.1": { inMiss: 0.3, inHit: 0.03, out: 1.2 },
64
66
  "MiniMax-M2": { inMiss: 0.3, inHit: 0.03, out: 1.2 },
65
67
  };
68
+ exports.DEFAULT_WINDOWS = {
69
+ // tokens (June 2026 published limits; conservative where ranges exist)
70
+ "deepseek-v4-flash": 128_000,
71
+ "deepseek-v4-pro": 128_000,
72
+ "deepseek-chat": 128_000,
73
+ "deepseek-reasoner": 128_000,
74
+ "gpt-5.1": 272_000,
75
+ "gpt-5.1-mini": 272_000,
76
+ "claude-opus-4-8": 200_000,
77
+ "claude-sonnet-4-6": 200_000,
78
+ "claude-haiku-4-5": 200_000,
79
+ "MiniMax-M2.1": 192_000,
80
+ "MiniMax-M2": 192_000,
81
+ };
66
82
  exports.DEFAULTS = {
67
83
  provider: "deepseek",
68
84
  providers: {},
@@ -105,7 +121,17 @@ exports.DEFAULTS = {
105
121
  hubPort: 7777,
106
122
  uiPort: 7780,
107
123
  pricing: exports.DEFAULT_PRICING,
124
+ contextWindows: exports.DEFAULT_WINDOWS,
108
125
  };
126
+ /**
127
+ * Effective compaction/trim threshold for a model: the configured limit,
128
+ * hard-capped by the model's known context window (15% headroom for output
129
+ * and estimation error). Models we don't know keep the configured limit.
130
+ */
131
+ function contextLimitFor(cfg, model) {
132
+ const win = cfg.contextWindows[model];
133
+ return win ? Math.min(cfg.contextTokenLimit, Math.floor(win * 0.85)) : cfg.contextTokenLimit;
134
+ }
109
135
  /**
110
136
  * Env vars that must never leak into agent shell commands when they execute
111
137
  * directly on the host: every provider key env plus the search/sandbox
@@ -163,9 +189,17 @@ function loadConfig() {
163
189
  provider,
164
190
  providers: file.providers || {},
165
191
  pricing: { ...exports.DEFAULT_PRICING, ...(file.pricing || {}) },
192
+ contextWindows: { ...exports.DEFAULT_WINDOWS, ...(file.contextWindows || {}) },
166
193
  apiKey: cred.apiKey || "",
167
194
  baseUrl: cred.baseUrl || info.baseUrl,
168
195
  };
196
+ // A cleared/hand-edited model must fall back, not brick every run with
197
+ // model:"" requests. (cheapModel/strongModel legitimately clear to "" —
198
+ // they mean "use `model`".)
199
+ if (!cfg.model)
200
+ cfg.model = info.defaultModel;
201
+ if (!cfg.conductorModel)
202
+ cfg.conductorModel = cfg.model;
169
203
  // Env overrides: provider-specific key env, plus legacy DEEPSEEK_API_KEY.
170
204
  if (info.keyEnv && process.env[info.keyEnv])
171
205
  cfg.apiKey = process.env[info.keyEnv];
@@ -223,6 +257,15 @@ function saveConfig(patch) {
223
257
  }
224
258
  return loadConfig();
225
259
  }
260
+ /**
261
+ * Config keys whose values must never print in cleartext — CLI output ends up
262
+ * in terminal scrollback and pasted bug reports. `providers` holds nested
263
+ * per-provider apiKeys, so it counts too. Single source of truth for the CLI
264
+ * masking sites (the hub's publicConfig is a strict allowlist already).
265
+ */
266
+ function isSecretConfigKey(key) {
267
+ return /apikey|token|secret/i.test(key) || key === "providers";
268
+ }
226
269
  function maskKey(key) {
227
270
  if (!key)
228
271
  return "";
@@ -138,7 +138,7 @@ async function firecrawlCrawl(cfg, opts, warnings) {
138
138
  warnings.push(`crawl still running after 120s; returning ${partial.length} partial pages`);
139
139
  return partial;
140
140
  }
141
- await sleep(pollMs, opts.signal);
141
+ await (0, util_1.sleep)(pollMs, opts.signal);
142
142
  }
143
143
  // Completed: collect pages, following `next` pagination until maxPages.
144
144
  const pages = mapFirecrawlPages(last);
@@ -206,18 +206,12 @@ function friendlyHttpError(service, status, body) {
206
206
  return new Error(`${service}: rate limited (HTTP 429) — retry later`);
207
207
  return new Error(`${service}: HTTP ${status} ${(0, util_1.truncateMiddle)(body, 300, "chars")}`);
208
208
  }
209
- function mergeSignal(timeoutMs, signal) {
210
- const t = AbortSignal.timeout(timeoutMs);
211
- if (!signal)
212
- return t;
213
- return typeof AbortSignal.any === "function" ? AbortSignal.any([t, signal]) : signal;
214
- }
215
209
  async function callJson(service, url, key, body, timeoutMs, signal) {
216
210
  const res = await fetch(url, {
217
211
  method: "POST",
218
212
  headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
219
213
  body: JSON.stringify(body),
220
- signal: mergeSignal(timeoutMs, signal),
214
+ signal: (0, util_1.mergeSignal)(timeoutMs, signal),
221
215
  });
222
216
  if (!res.ok)
223
217
  throw friendlyHttpError(service, res.status, await res.text().catch(() => ""));
@@ -226,22 +220,9 @@ async function callJson(service, url, key, body, timeoutMs, signal) {
226
220
  async function getJson(service, url, key, signal) {
227
221
  const res = await fetch(url, {
228
222
  headers: { authorization: `Bearer ${key}` },
229
- signal: mergeSignal(30_000, signal),
223
+ signal: (0, util_1.mergeSignal)(30_000, signal),
230
224
  });
231
225
  if (!res.ok)
232
226
  throw friendlyHttpError(service, res.status, await res.text().catch(() => ""));
233
227
  return res.json();
234
228
  }
235
- function sleep(ms, signal) {
236
- return new Promise((resolve, reject) => {
237
- const t = setTimeout(() => {
238
- signal?.removeEventListener("abort", onAbort);
239
- resolve();
240
- }, ms);
241
- const onAbort = () => {
242
- clearTimeout(t);
243
- reject(new Error("aborted"));
244
- };
245
- signal?.addEventListener("abort", onAbort, { once: true });
246
- });
247
- }