@saadnvd1/agent-os 0.3.738 → 0.3.743

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 (41) hide show
  1. package/components/Chat/Activity.test.ts +2 -1
  2. package/components/Chat/Activity.tsx +102 -64
  3. package/components/Chat/ChatPanel.tsx +4 -0
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Tools.tsx +42 -5
  6. package/data/chat/useChat.ts +10 -1
  7. package/lib/chat/demo.ts +42 -4
  8. package/lib/chat/drivers/claude-mapper.ts +10 -0
  9. package/lib/chat/drivers/claude.ts +9 -1
  10. package/lib/chat/drivers/demo-script.ts +148 -0
  11. package/lib/chat/drivers/demo.ts +21 -4
  12. package/lib/chat/drivers/edit-guard.ts +311 -0
  13. package/lib/chat/drivers/index.ts +2 -2
  14. package/lib/chat/drivers/opencode-config.ts +111 -0
  15. package/lib/chat/drivers/opencode-mapper.ts +125 -13
  16. package/lib/chat/drivers/opencode-plugin.ts +78 -0
  17. package/lib/chat/drivers/opencode-server.ts +7 -2
  18. package/lib/chat/drivers/opencode.ts +108 -9
  19. package/lib/chat/drivers/pi-edit-guard.ts +87 -0
  20. package/lib/chat/drivers/pi-mapper.ts +74 -1
  21. package/lib/chat/drivers/pi.ts +82 -3
  22. package/lib/chat/drivers/transient-retry.ts +87 -0
  23. package/lib/chat/drivers/turn-items.ts +74 -1
  24. package/lib/chat/drivers/turn-outcome.ts +48 -1
  25. package/lib/chat/events.ts +15 -1
  26. package/lib/chat/model-wait-live.ts +19 -0
  27. package/lib/chat/model-wait.ts +88 -0
  28. package/lib/chat/progress.ts +169 -0
  29. package/lib/chat/registry.ts +5 -0
  30. package/lib/chat/runner-watch.ts +71 -0
  31. package/lib/chat/runner.ts +4 -70
  32. package/lib/chat/tools.ts +14 -0
  33. package/lib/security/demo.ts +8 -0
  34. package/lib/tasks/move-testing.ts +6 -1
  35. package/package.json +1 -1
  36. package/scripts/screenshots/run.ts +27 -3
  37. package/scripts/screenshots/running-turn.ts +54 -0
  38. package/scripts/screenshots/seed.ts +15 -3
  39. package/scripts/screenshots/shoot.ts +3 -1
  40. package/scripts/screenshots/shots.ts +5 -0
  41. package/server.ts +10 -2
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * The demo's stand-in for an agent (AGENTOS_DEMO=1): no process, no tools,
3
3
  * just a short canned reply streamed the way a real one is, so the chat
4
- * flow still works end to end.
4
+ * flow still works end to end. A typed `demo: running turn` runs the
5
+ * scripted turn in demo-script.ts instead.
5
6
  */
6
7
 
7
8
  import type { ChatConversation, ChatDriver } from "../driver";
8
9
  import type { DriverEvent } from "../events";
10
+ import { parseDemoScript, runDemoScript, type DemoScript } from "./demo-script";
9
11
 
10
12
  export const DEMO_REPLY =
11
13
  "This is a demo, so agents don't run here. On your own machine, this is where the agent would answer and get to work.";
12
14
 
13
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
15
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
14
16
 
15
17
  // Events pushed by sends and read by one consumer, in order.
16
18
  function channel() {
@@ -89,12 +91,27 @@ export function demoConversation(stepMs = 40): ChatConversation {
89
91
  });
90
92
  out.push({ type: "state", state: "idle" });
91
93
  };
94
+ // A scripted turn that a newer one took over from says nothing more.
95
+ let generation = 0;
96
+ const script = async (s: DemoScript, mine: number) => {
97
+ if (closed) return;
98
+ stopped = false;
99
+ await runDemoScript(s, {
100
+ push: (e) => mine === generation && out.push(e),
101
+ stopped: () => stopped || closed,
102
+ sleep,
103
+ });
104
+ };
92
105
  const refuse = async () => {
93
106
  throw new Error("Not available in the demo.");
94
107
  };
95
108
  return {
96
- send() {
97
- turn = turn.then(reply);
109
+ send(text) {
110
+ const s = parseDemoScript(text);
111
+ // A scripted turn takes over from one still running.
112
+ const mine = s ? ++generation : generation;
113
+ if (s) stopped = true;
114
+ turn = turn.then(s ? () => script(s, mine) : reply);
98
115
  return undefined;
99
116
  },
100
117
  async interrupt() {
@@ -0,0 +1,311 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { createRequire } from "module";
4
+ import { agentosHome } from "../../agentos-home";
5
+
6
+ // What a model needs after an edit goes wrong, for Pi and OpenCode alike
7
+ // (their edit tools are theirs, so this runs beside them as an extension and
8
+ // a plugin):
9
+ //
10
+ // - an edit whose old text isn't in the file is refused before it runs, with
11
+ // the file's current text around the closest match, and a repeat of an edit
12
+ // that already failed is called out;
13
+ // - after an edit or write to a TypeScript or JavaScript file, new syntax
14
+ // errors and block-scoped names declared twice come back in the tool result.
15
+ //
16
+ // Plain ESM with no imports beyond node's, so Node (Pi) and Bun (OpenCode)
17
+ // both run it. It never throws and never blocks on anything it can't read.
18
+ // Written as a string, like the other extensions, and exported for its test.
19
+ export const EDIT_GUARD_LIB = String.raw`import { createHash } from "node:crypto";
20
+ import { readFileSync, statSync } from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import { extname, resolve } from "node:path";
23
+
24
+ const SOURCE = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
25
+ const MAX_BYTES = 1048576;
26
+ const MAX_REPORTED = 10;
27
+ const EXCERPT_LINES = 40;
28
+ const EXCERPT_CHARS = 3000;
29
+
30
+ const norm = (s) => String(s).replace(/\r\n/g, "\n");
31
+ const trimmed = (s) => norm(s).split("\n").map((l) => l.trim());
32
+ const collapse = (s) => norm(s).replace(/\s+/g, " ").trim();
33
+
34
+ export function readText(file) {
35
+ try {
36
+ if (statSync(file).size > MAX_BYTES) return null;
37
+ return readFileSync(file, "utf8");
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export const absolute = (file, cwd) =>
44
+ resolve(cwd || process.cwd(), String(file).replace(/^@/, ""));
45
+
46
+ function needLines(old) {
47
+ const need = trimmed(old);
48
+ while (need.length && need[need.length - 1] === "") need.pop();
49
+ while (need.length && need[0] === "") need.shift();
50
+ return need;
51
+ }
52
+
53
+ // Whether any of the harnesses' matchers could find this text: exact, line by
54
+ // line without indentation, with whitespace collapsed, or by its first and
55
+ // last lines. Only text none of them find is refused, so an edit a harness
56
+ // would have accepted is never blocked here.
57
+ export function isFound(text, old) {
58
+ if (typeof old !== "string" || !old.trim()) return true;
59
+ if (text.includes(old) || norm(text).includes(norm(old))) return true;
60
+ const hay = trimmed(text);
61
+ const need = needLines(old);
62
+ if (!need.length) return true;
63
+ for (let i = 0; i + need.length <= hay.length; i++) {
64
+ let same = true;
65
+ for (let j = 0; j < need.length && same; j++) same = hay[i + j] === need[j];
66
+ if (same) return true;
67
+ }
68
+ if (collapse(text).includes(collapse(old))) return true;
69
+ if (need.length >= 3) {
70
+ const last = need[need.length - 1];
71
+ for (let i = 0; i < hay.length; i++) {
72
+ if (hay[i] !== need[0]) continue;
73
+ const to = Math.min(hay.length, i + need.length + 10);
74
+ for (let k = i + 2; k < to; k++) if (hay[k] === last) return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // The file's lines around where the old text most likely was, numbered, or
81
+ // null when nothing in the file resembles it.
82
+ export function closestMatch(text, old) {
83
+ const lines = norm(text).split("\n");
84
+ const hay = lines.map((l) => l.trim());
85
+ const need = trimmed(old);
86
+ const real = need.filter((l) => l.length > 2);
87
+ if (!real.length) return null;
88
+ const anchor = real.reduce((a, b) => (b.length > a.length ? b : a));
89
+ const offset = need.indexOf(anchor);
90
+ let hits = [];
91
+ hay.forEach((l, i) => l === anchor && hits.push(i));
92
+ if (!hits.length) hay.forEach((l, i) => l.length > 2 && (l.includes(anchor) || anchor.includes(l)) && hits.push(i));
93
+ if (!hits.length) {
94
+ const words = (l) => new Set(l.toLowerCase().split(/[^a-z0-9_$]+/).filter(Boolean));
95
+ const want = words(anchor);
96
+ let top = 0.5;
97
+ hay.forEach((l, i) => {
98
+ const have = words(l);
99
+ let shared = 0;
100
+ for (const w of have) if (want.has(w)) shared++;
101
+ const dice = (2 * shared) / (want.size + have.size || 1);
102
+ if (dice > top) {
103
+ top = dice;
104
+ hits = [i];
105
+ }
106
+ });
107
+ }
108
+ if (!hits.length) return null;
109
+ let best = { score: -1, start: 0 };
110
+ for (const h of hits.slice(0, 200)) {
111
+ const start = Math.max(0, h - offset);
112
+ let score = 0;
113
+ for (let j = 0; j < need.length; j++) if (hay[start + j] === need[j] && need[j]) score++;
114
+ if (score > best.score) best = { score, start };
115
+ }
116
+ const from = Math.max(0, best.start - 3);
117
+ const to = Math.min(lines.length, best.start + need.length + 3);
118
+ let shown = lines.slice(from, to);
119
+ if (shown.length > EXCERPT_LINES) shown = shown.slice(0, EXCERPT_LINES);
120
+ let excerpt = shown.map((l, i) => (from + i + 1) + "\t" + l).join("\n");
121
+ if (excerpt.length > EXCERPT_CHARS) excerpt = excerpt.slice(0, EXCERPT_CHARS) + "\n…";
122
+ return { from: from + 1, to: from + shown.length, excerpt };
123
+ }
124
+
125
+ export function failureMessage(file, old, text, repeat) {
126
+ const near = closestMatch(text, old);
127
+ const out = [
128
+ "The old text was not found in " + file + ". The file is not what this edit assumes: it changed since you last read it, or the text is not exactly what is there.",
129
+ ];
130
+ if (repeat >= 2)
131
+ out.push("This is the same edit (same file, same old text) that has already failed " + (repeat - 1) + " time" + (repeat > 2 ? "s" : "") + ". Do not send it again unchanged.");
132
+ if (near) out.push("The file's current text near the closest match (lines " + near.from + "-" + near.to + "):", near.excerpt);
133
+ else out.push("Nothing in the file resembles that text (" + norm(text).split("\n").length + " lines).");
134
+ out.push("Read " + file + " again and build the edit from what is there now.");
135
+ return out.join("\n");
136
+ }
137
+
138
+ // Failed edits by file and old text, so a repeat can be named. A read, a
139
+ // successful edit or a write of the file starts it over.
140
+ export class FailureLog {
141
+ constructor() {
142
+ this.counts = new Map();
143
+ }
144
+ key(file, old) {
145
+ return file + "\0" + createHash("sha1").update(String(old)).digest("hex");
146
+ }
147
+ record(file, old) {
148
+ const key = this.key(file, old);
149
+ const n = (this.counts.get(key) || 0) + 1;
150
+ this.counts.set(key, n);
151
+ return n;
152
+ }
153
+ clear(file) {
154
+ for (const key of [...this.counts.keys()]) if (key.startsWith(file + "\0")) this.counts.delete(key);
155
+ }
156
+ }
157
+
158
+ // The refusal for an edit whose old text isn't in the file, or null to let
159
+ // it run. Several edits to one file: the first missing one is named.
160
+ export function guardEdit(log, file, oldTexts, cwd) {
161
+ try {
162
+ const path = absolute(file, cwd);
163
+ const text = readText(path);
164
+ if (text == null) return null;
165
+ const missing = oldTexts.find((o) => !isFound(text, o));
166
+ if (missing === undefined) return null;
167
+ return failureMessage(file, missing, text, log.record(path, missing));
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ const tsByRoot = new Map();
174
+ function loadTs(cwd) {
175
+ const root = resolve(cwd || process.cwd());
176
+ const cacheKey = root + "|" + (process.env.AGENTOS_TYPESCRIPT || "");
177
+ if (tsByRoot.has(cacheKey)) return tsByRoot.get(cacheKey);
178
+ let found = null;
179
+ const tries = [
180
+ () => createRequire(resolve(root, "package.json"))("typescript"),
181
+ () => (process.env.AGENTOS_TYPESCRIPT ? createRequire(import.meta.url)(process.env.AGENTOS_TYPESCRIPT) : null),
182
+ ];
183
+ for (const t of tries) {
184
+ try {
185
+ const m = t();
186
+ if (m && typeof m.createSourceFile === "function") {
187
+ found = m;
188
+ break;
189
+ }
190
+ } catch {}
191
+ }
192
+ tsByRoot.set(cacheKey, found);
193
+ return found;
194
+ }
195
+
196
+ function bindingNames(ts, name, out) {
197
+ if (ts.isIdentifier(name)) out.push(name);
198
+ else for (const el of name.elements) if (!ts.isOmittedExpression(el)) bindingNames(ts, el.name, out);
199
+ }
200
+
201
+ function duplicates(ts, sf) {
202
+ const found = [];
203
+ const check = (statements) => {
204
+ const lexical = new Map();
205
+ const hoisted = new Map();
206
+ const add = (map, id, kind) => {
207
+ const line = sf.getLineAndCharacterOfPosition(id.getStart(sf)).line + 1;
208
+ if (!map.has(id.text)) map.set(id.text, []);
209
+ map.get(id.text).push({ kind, line });
210
+ };
211
+ for (const st of statements) {
212
+ if (ts.isVariableStatement(st)) {
213
+ const flags = st.declarationList.flags;
214
+ const kind = flags & ts.NodeFlags.Const ? "const" : flags & ts.NodeFlags.Let ? "let" : "var";
215
+ const ids = [];
216
+ for (const d of st.declarationList.declarations) bindingNames(ts, d.name, ids);
217
+ for (const id of ids) add(kind === "var" ? hoisted : lexical, id, kind);
218
+ } else if (ts.isClassDeclaration(st) && st.name) add(lexical, st.name, "class");
219
+ else if (ts.isFunctionDeclaration(st) && st.name) add(hoisted, st.name, "function");
220
+ }
221
+ for (const [name, at] of lexical) {
222
+ const all = at.concat(hoisted.get(name) || []);
223
+ if (all.length > 1) found.push({ name, kind: at[0].kind, lines: all.map((a) => a.line).sort((a, b) => a - b) });
224
+ }
225
+ };
226
+ const visit = (node) => {
227
+ if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) check(node.statements);
228
+ else if (ts.isCaseBlock(node)) check(node.clauses.flatMap((c) => [...c.statements]));
229
+ ts.forEachChild(node, visit);
230
+ };
231
+ visit(sf);
232
+ return found;
233
+ }
234
+
235
+ function problems(ts, file, text) {
236
+ const out = [];
237
+ const t = ts.transpileModule(text, {
238
+ fileName: file,
239
+ reportDiagnostics: true,
240
+ compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, isolatedModules: true },
241
+ });
242
+ for (const d of t.diagnostics || []) {
243
+ const at = d.file && d.start != null ? d.file.getLineAndCharacterOfPosition(d.start) : null;
244
+ const message = ts.flattenDiagnosticMessageText(d.messageText, " ");
245
+ out.push({ key: message, text: (at ? (at.line + 1) + ":" + (at.character + 1) + " " : "") + message });
246
+ }
247
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
248
+ for (const d of duplicates(ts, sf))
249
+ out.push({
250
+ key: "duplicate " + d.name,
251
+ text: d.kind + " " + d.name + " is declared more than once in the same block (lines " + d.lines.join(", ") + ")",
252
+ });
253
+ return out;
254
+ }
255
+
256
+ // What an edit broke in a source file: the problems it has now that it didn't
257
+ // before, as text for the tool result, or "" when there are none, the file
258
+ // isn't source, or TypeScript can't be loaded. Syntax only, never types.
259
+ export function syntaxReport(file, before, after, cwd) {
260
+ try {
261
+ if (!SOURCE.has(extname(file).toLowerCase()) || typeof after !== "string" || after.length > MAX_BYTES) return "";
262
+ const ts = loadTs(cwd);
263
+ if (!ts) return "";
264
+ const had = new Map();
265
+ for (const p of before == null || before.length > MAX_BYTES ? [] : problems(ts, file, before)) had.set(p.key, (had.get(p.key) || 0) + 1);
266
+ const fresh = [];
267
+ for (const p of problems(ts, file, after)) {
268
+ const n = had.get(p.key) || 0;
269
+ if (n > 0) had.set(p.key, n - 1);
270
+ else fresh.push(p);
271
+ }
272
+ if (!fresh.length) return "";
273
+ const shown = fresh.slice(0, MAX_REPORTED).map((p) => "- " + p.text);
274
+ if (fresh.length > shown.length) shown.push("- and " + (fresh.length - shown.length) + " more");
275
+ return "[AgentOS syntax check] This change left problems in " + file + " that were not there before. Fix them now:\n" + shown.join("\n");
276
+ } catch {
277
+ return "";
278
+ }
279
+ }
280
+ `;
281
+
282
+ const dir = () => agentosHome("edit-guard");
283
+
284
+ // Owner-only, like the other extension folders.
285
+ export function writeEditGuard(
286
+ name: string,
287
+ source: string,
288
+ folder = dir()
289
+ ): string {
290
+ fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
291
+ fs.chmodSync(folder, 0o700);
292
+ for (const [file, text] of [
293
+ ["agentos-edit-guard-lib.mjs", EDIT_GUARD_LIB],
294
+ [name, source],
295
+ ]) {
296
+ const target = path.join(folder, file);
297
+ if (!fs.existsSync(target) || fs.readFileSync(target, "utf8") !== text)
298
+ fs.writeFileSync(target, text);
299
+ }
300
+ return path.join(folder, name);
301
+ }
302
+
303
+ // The TypeScript this server runs on, for projects that have none of their
304
+ // own, or undefined where there isn't one.
305
+ export function typescriptPath(): string | undefined {
306
+ try {
307
+ return createRequire(import.meta.url).resolve("typescript");
308
+ } catch {
309
+ return undefined;
310
+ }
311
+ }
@@ -6,7 +6,7 @@ import { openCodeDriver } from "./opencode";
6
6
  import { piDriver } from "./pi";
7
7
  import { copilotDriver } from "./copilot";
8
8
  import { demoDriver } from "./demo";
9
- import { demoMode } from "../../security/demo";
9
+ import { demoChatMode, demoMode } from "../../security/demo";
10
10
 
11
11
  // One driver per agent CLI. Adding a CLI means a driver here and its agent
12
12
  // type in CHAT_AGENT_TYPES.
@@ -21,5 +21,5 @@ const DRIVERS: Record<(typeof CHAT_AGENT_TYPES)[number], ChatDriver> = {
21
21
  export function chatDriverFor(agentType: string): ChatDriver | null {
22
22
  const driver = DRIVERS[agentType as keyof typeof DRIVERS] ?? null;
23
23
  // A demo never starts a real agent (lib/chat/demo runs this one).
24
- return driver && demoMode() ? demoDriver : driver;
24
+ return driver && (demoMode() || demoChatMode()) ? demoDriver : driver;
25
25
  }
@@ -0,0 +1,111 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ type P = Record<string, unknown>;
7
+
8
+ // How long OpenCode waits for a model server's response headers (its default
9
+ // is 5 minutes). A local model chewing through a long context sends nothing
10
+ // until it has read all of it, and a retry at the same limit fails the same
11
+ // way. The wait between chunks of a streamed reply keeps OpenCode's own 5
12
+ // minute limit, so a stream that started is judged on idle time and a server
13
+ // that never answers still ends the request.
14
+ export const HEADER_TIMEOUT_MS = 1_200_000;
15
+
16
+ const isObject = (v: unknown): v is P =>
17
+ !!v && typeof v === "object" && !Array.isArray(v);
18
+
19
+ // JSON with comments and trailing commas (OpenCode's config allows both),
20
+ // parsed without touching what is inside strings. Null when it isn't JSON.
21
+ export function parseJsonc(text: string): P | null {
22
+ let out = "";
23
+ for (let i = 0; i < text.length; i++) {
24
+ const c = text[i];
25
+ if (c === '"') {
26
+ let j = i + 1;
27
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
28
+ out += text.slice(i, j + 1);
29
+ i = j;
30
+ } else if (c === "/" && text[i + 1] === "/") {
31
+ while (i < text.length && text[i] !== "\n") i++;
32
+ out += "\n";
33
+ } else if (c === "/" && text[i + 1] === "*") {
34
+ const end = text.indexOf("*/", i + 2);
35
+ i = end < 0 ? text.length : end + 1;
36
+ } else out += c;
37
+ }
38
+ try {
39
+ const v: unknown = JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
40
+ return isObject(v) ? v : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function read(file: string): P | null {
47
+ try {
48
+ return parseJsonc(fs.readFileSync(file, "utf8"));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function userConfigs(cwd: string, env: NodeJS.ProcessEnv): P[] {
55
+ const home = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
56
+ const files = [
57
+ ...["opencode.json", "opencode.jsonc", "config.json"].map((f) =>
58
+ path.join(home, "opencode", f)
59
+ ),
60
+ ...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
61
+ ...["opencode.json", "opencode.jsonc"].flatMap((f) => [
62
+ path.join(cwd, f),
63
+ path.join(cwd, ".opencode", f),
64
+ ]),
65
+ ];
66
+ return files.map(read).filter((c): c is P => !!c);
67
+ }
68
+
69
+ // The OPENCODE_CONFIG_CONTENT for one conversation: the user's own value
70
+ // (never replaced, their providers are in it) plus the edit guard plugin and
71
+ // a longer header wait for each provider in their config and for the
72
+ // conversation's model, unless they set one themselves.
73
+ export function openCodeConfigContent(o: {
74
+ cwd: string;
75
+ env: NodeJS.ProcessEnv;
76
+ // "provider/model", the conversation's.
77
+ model?: string;
78
+ pluginFile?: string;
79
+ }): string {
80
+ const given = o.env.OPENCODE_CONFIG_CONTENT;
81
+ const base = given ? parseJsonc(given) : {};
82
+ if (!base) return given as string;
83
+
84
+ const own = new Set<string>();
85
+ const set = new Set<string>();
86
+ const slash = o.model?.indexOf("/") ?? -1;
87
+ if (o.model && slash > 0) set.add(o.model.slice(0, slash));
88
+ for (const c of [...userConfigs(o.cwd, o.env), base]) {
89
+ if (!isObject(c.provider)) continue;
90
+ for (const [id, p] of Object.entries(c.provider)) {
91
+ set.add(id);
92
+ const options = isObject(p) && isObject(p.options) ? p.options : {};
93
+ if (options.headerTimeout !== undefined) own.add(id);
94
+ }
95
+ }
96
+
97
+ const provider: P = isObject(base.provider) ? { ...base.provider } : {};
98
+ for (const id of set) {
99
+ if (own.has(id)) continue;
100
+ const p = isObject(provider[id]) ? (provider[id] as P) : {};
101
+ const options = isObject(p.options) ? p.options : {};
102
+ provider[id] = {
103
+ ...p,
104
+ options: { ...options, headerTimeout: HEADER_TIMEOUT_MS },
105
+ };
106
+ }
107
+
108
+ const plugin = Array.isArray(base.plugin) ? [...base.plugin] : [];
109
+ if (o.pluginFile) plugin.push(pathToFileURL(o.pluginFile).href);
110
+ return JSON.stringify({ ...base, provider, plugin });
111
+ }