@gigaai/newton 0.3.0 → 0.4.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/dist/executor.js CHANGED
@@ -1,11 +1,62 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { cpus } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { AcpAgentSession } from "./acp.js";
5
- import { agentCallKey, loadResumeCache, RunJournal } from "./journal.js";
5
+ import { readSkipRequests, writeStatus } from "./control.js";
6
+ import { agentCallKey, loadResumeCache, loadResumeCalls, RunJournal } from "./journal.js";
7
+ import { resolveMcpServers } from "./mcp.js";
8
+ import { checkCompile, createRealm } from "./sandbox.js";
6
9
  import { balancedSlice, extractJson, INVALID, structuredOutputInstruction, structuredOutputRetryPrompt, validateAgainstSchema, } from "./schema.js";
7
- const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
8
- const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "Date", "Math"];
10
+ import { createWorktree, finishWorktree } from "./worktree.js";
11
+ const SCRIPT_PARAMS = ["agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow"];
12
+ const AGENT_MAX_ATTEMPTS = 3;
13
+ const AGENT_RETRY_BASE_MS = 2_000;
14
+ /**
15
+ * Failures worth a fresh attempt: provider 5xx/overload/rate-limit responses
16
+ * and dropped connections. Deterministic failures (auth, missing binaries,
17
+ * bad model ids, structured-output exhaustion) return null without retrying.
18
+ */
19
+ const TRANSIENT_AGENT_ERROR = new RegExp([
20
+ "\\b(?:429|500|502|503|504|529)\\b",
21
+ "overloaded",
22
+ "rate.?limit",
23
+ "too many requests",
24
+ "internal server error",
25
+ "service unavailable",
26
+ "bad gateway",
27
+ "gateway timeout",
28
+ "econn(?:reset|refused|aborted)",
29
+ "etimedout",
30
+ "epipe",
31
+ "eai_again",
32
+ "socket hang.?up",
33
+ "connection (?:reset|refused|closed|error)",
34
+ "fetch failed",
35
+ "network error",
36
+ "stream (?:closed|disconnected)",
37
+ ].join("|"), "i");
38
+ function errorMessage(error) {
39
+ return typeof error === "object" &&
40
+ error !== null &&
41
+ "message" in error &&
42
+ typeof error.message === "string"
43
+ ? error.message
44
+ : String(error);
45
+ }
46
+ function isPlainObject(value) {
47
+ if (Object.prototype.toString.call(value) !== "[object Object]")
48
+ return false;
49
+ const proto = Object.getPrototypeOf(value);
50
+ return proto === null || Object.getPrototypeOf(proto) === null;
51
+ }
52
+ function validateConfig(value) {
53
+ if (value === undefined)
54
+ return undefined;
55
+ if (!isPlainObject(value) || Object.values(value).some((entry) => typeof entry !== "string")) {
56
+ throw new Error("agent() config takes an object of configId → string value");
57
+ }
58
+ return value;
59
+ }
9
60
  export function loadWorkflow(file) {
10
61
  const absolute = resolve(file);
11
62
  const source = readFileSync(absolute, "utf8");
@@ -33,33 +84,8 @@ export function loadWorkflow(file) {
33
84
  }
34
85
  /** Compile without running — catches syntax errors and non-literal meta. */
35
86
  export function compileWorkflow(workflow) {
36
- new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
87
+ checkCompile(SCRIPT_PARAMS, workflow.body);
37
88
  }
38
- // Wall-clock and randomness would silently break resume replay, so scripts get
39
- // guarded shims instead of the real globals. Pass timestamps in via --args.
40
- const guardedDate = new Proxy(Date, {
41
- get(target, prop, receiver) {
42
- if (prop === "now") {
43
- return () => {
44
- throw new Error("Date.now() is unavailable in workflows (breaks resume); pass timestamps via --args");
45
- };
46
- }
47
- return Reflect.get(target, prop, receiver);
48
- },
49
- construct(target, argArray) {
50
- if (argArray.length === 0) {
51
- throw new Error("new Date() without arguments is unavailable in workflows (breaks resume)");
52
- }
53
- return Reflect.construct(target, argArray);
54
- },
55
- });
56
- const guardedMath = Object.create(Math, {
57
- random: {
58
- value: () => {
59
- throw new Error("Math.random() is unavailable in workflows (breaks resume); vary prompts by index instead");
60
- },
61
- },
62
- });
63
89
  class Semaphore {
64
90
  queue = [];
65
91
  available;
@@ -89,11 +115,13 @@ const AGENT_PREAMBLE = [
89
115
  ].join("\n");
90
116
  export async function runWorkflow(options) {
91
117
  const startedAt = Date.now();
118
+ const statusStartedAt = new Date().toISOString();
92
119
  const workflow = loadWorkflow(options.file);
93
- const scriptFn = new AsyncFunction(...SCRIPT_PARAMS, workflow.body);
94
120
  const workspace = resolve(options.cwd);
95
- const journal = new RunJournal(workspace);
96
- const resumeCache = options.resumeRunId ? loadResumeCache(workspace, options.resumeRunId) : undefined;
121
+ const journal = new RunJournal(workspace, options.runId);
122
+ const resumeMode = options.resumeMode ?? "prefix";
123
+ const resumeCache = options.resumeRunId && resumeMode === "hash" ? loadResumeCache(workspace, options.resumeRunId) : undefined;
124
+ const priorCalls = options.resumeRunId && resumeMode !== "hash" ? loadResumeCalls(workspace, options.resumeRunId) : undefined;
97
125
  const reporter = options.reporter;
98
126
  const maxAgents = options.maxAgents ?? 200;
99
127
  const agentTimeoutMs = options.agentTimeoutMs ?? 20 * 60 * 1000;
@@ -103,6 +131,33 @@ export async function runWorkflow(options) {
103
131
  let agentCounter = 0;
104
132
  let outputTokens = 0;
105
133
  let currentPhase;
134
+ let prefixIntact = true;
135
+ const liveSessions = new Map();
136
+ const skipped = new Set();
137
+ const controlTimer = setInterval(() => {
138
+ for (const id of readSkipRequests(journal.runDir)) {
139
+ if (skipped.has(id))
140
+ continue;
141
+ skipped.add(id);
142
+ liveSessions.get(id)?.abort("skipped via newton skip");
143
+ }
144
+ }, 500);
145
+ controlTimer.unref();
146
+ function syncStatus(status, extra = {}) {
147
+ writeStatus(journal.runDir, {
148
+ runId: journal.runId,
149
+ pid: process.pid,
150
+ file: workflow.file,
151
+ name: workflow.meta.name,
152
+ detached: options.detached ?? false,
153
+ startedAt: statusStartedAt,
154
+ updatedAt: new Date().toISOString(),
155
+ status,
156
+ agents: agentCounter,
157
+ outputTokens,
158
+ ...extra,
159
+ });
160
+ }
106
161
  journal.append({
107
162
  type: "workflow_start",
108
163
  runId: journal.runId,
@@ -110,8 +165,10 @@ export async function runWorkflow(options) {
110
165
  name: workflow.meta.name,
111
166
  args: options.args ?? null,
112
167
  resumedFrom: options.resumeRunId ?? null,
168
+ resumeMode: options.resumeRunId ? resumeMode : null,
113
169
  });
114
170
  reporter.start(workflow.meta, journal.runId, journal.runDir);
171
+ syncStatus("running");
115
172
  const budget = {
116
173
  total: budgetTotal,
117
174
  spent: () => outputTokens,
@@ -135,61 +192,168 @@ export async function runWorkflow(options) {
135
192
  if (budgetTotal !== null && outputTokens >= budgetTotal) {
136
193
  throw new Error(`token budget exhausted (${outputTokens}/${budgetTotal} output tokens)`);
137
194
  }
138
- const key = agentCallKey(prompt, spec, callOptions.schema ?? null);
195
+ const config = validateConfig(callOptions.config);
196
+ const key = agentCallKey(prompt, { ...spec, isolation: callOptions.isolation, mcp: callOptions.mcp, config }, callOptions.schema ?? null);
139
197
  const label = callOptions.label ?? prompt.replace(/\s+/g, " ").trim().slice(0, 64);
140
198
  const phase = callOptions.phase ?? currentPhase;
199
+ if (priorCalls && prefixIntact) {
200
+ const prior = priorCalls.get(id);
201
+ if (!prior || prior.key !== key) {
202
+ prefixIntact = false;
203
+ }
204
+ else if (prior.ok) {
205
+ journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result: prior.result });
206
+ syncStatus("running");
207
+ reporter.agentStart(id, label, spec, phase, true);
208
+ reporter.agentEnd(id, true, 0, "cached");
209
+ return prior.result;
210
+ }
211
+ }
141
212
  const cachedQueue = resumeCache?.get(key);
142
213
  if (cachedQueue && cachedQueue.length > 0) {
143
214
  const result = cachedQueue.shift();
144
215
  journal.append({ type: "agent_end", id, key, label, phase, status: "ok", cached: true, result });
216
+ syncStatus("running");
145
217
  reporter.agentStart(id, label, spec, phase, true);
146
218
  reporter.agentEnd(id, true, 0, "cached");
147
219
  return result;
148
220
  }
221
+ const mcpServers = callOptions.mcp !== undefined ? resolveMcpServers(workspace, callOptions.mcp) : undefined;
149
222
  await semaphore.acquire();
150
223
  const agentStartedAt = Date.now();
151
- reporter.agentStart(id, label, spec, phase, false);
152
- journal.append({ type: "agent_start", id, key, label, phase, spec, prompt });
153
- const trace = journal.traceWriter(id);
224
+ let session;
225
+ let wt;
226
+ let worktreeFinished = false;
227
+ // Idempotent so the finally can guarantee cleanup on a throw without
228
+ // double-finalizing the worktree that the normal path already removed.
229
+ const finishCreatedWorktree = async () => {
230
+ if (!wt || worktreeFinished)
231
+ return undefined;
232
+ worktreeFinished = true;
233
+ const wtInfo = await finishWorktree(workspace, wt);
234
+ const worktree = { path: wt.path, dirty: wtInfo.dirty, removed: wtInfo.removed };
235
+ if (wtInfo.dirty)
236
+ reporter.log(`worktree kept: ${wt.path}`);
237
+ return worktree;
238
+ };
154
239
  try {
240
+ reporter.agentStart(id, label, spec, phase, false);
241
+ journal.append({ type: "agent_start", id, key, label, phase, spec, prompt });
242
+ const trace = journal.traceWriter(id);
243
+ if (skipped.has(id)) {
244
+ journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: "skipped via newton skip", seconds: 0 });
245
+ syncStatus("running");
246
+ reporter.agentEnd(id, false, 0, "skipped");
247
+ return null;
248
+ }
249
+ if (callOptions.isolation === "worktree") {
250
+ try {
251
+ wt = await createWorktree(workspace, journal.runId, id);
252
+ }
253
+ catch (error) {
254
+ const message = errorMessage(error);
255
+ journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: message, seconds: 0 });
256
+ syncStatus("running");
257
+ reporter.agentEnd(id, false, 0, message);
258
+ return null;
259
+ }
260
+ }
261
+ if (skipped.has(id)) {
262
+ const worktree = await finishCreatedWorktree();
263
+ journal.append({
264
+ type: "agent_end",
265
+ id,
266
+ key,
267
+ label,
268
+ phase,
269
+ status: "error",
270
+ error: "skipped via newton skip",
271
+ seconds: 0,
272
+ ...(worktree ? { worktree } : {}),
273
+ });
274
+ syncStatus("running");
275
+ reporter.agentEnd(id, false, 0, "skipped");
276
+ return null;
277
+ }
155
278
  const fullPrompt = AGENT_PREAMBLE + prompt + (callOptions.schema ? structuredOutputInstruction(callOptions.schema) : "");
156
- const session = new AcpAgentSession({
157
- spec,
158
- prompt: fullPrompt,
159
- cwd: callOptions.cwd ? resolve(workspace, callOptions.cwd) : workspace,
160
- timeoutMs: callOptions.timeoutMs ?? agentTimeoutMs,
161
- trace,
162
- });
163
- let turn = await session.prompt();
164
- let result = turn.text;
165
- let failure = turn.error;
166
- if (!failure && callOptions.schema) {
167
- for (let attempt = 0;; attempt++) {
168
- const parsed = extractJson(turn.text);
169
- const errors = parsed === INVALID
170
- ? ["response contained no parseable JSON"]
171
- : validateAgainstSchema(parsed, callOptions.schema);
172
- if (errors.length === 0) {
173
- result = parsed;
174
- break;
175
- }
176
- if (attempt >= 2) {
177
- failure = `structured output failed after ${attempt + 1} attempts: ${errors.join("; ")}`;
178
- break;
179
- }
180
- turn = await session.promptAgain(structuredOutputRetryPrompt(errors, callOptions.schema));
181
- if (turn.error) {
182
- failure = turn.error;
183
- break;
279
+ const cwd = wt
280
+ ? callOptions.cwd
281
+ ? resolve(wt.path, callOptions.cwd)
282
+ : wt.path
283
+ : callOptions.cwd
284
+ ? resolve(workspace, callOptions.cwd)
285
+ : workspace;
286
+ let turn;
287
+ let result;
288
+ let failure;
289
+ for (let attempt = 1;; attempt++) {
290
+ session = new AcpAgentSession({
291
+ spec,
292
+ prompt: fullPrompt,
293
+ cwd,
294
+ timeoutMs: callOptions.timeoutMs ?? agentTimeoutMs,
295
+ mcpServers,
296
+ config,
297
+ trace,
298
+ });
299
+ liveSessions.set(id, session);
300
+ turn = await session.prompt();
301
+ result = turn.text;
302
+ failure = turn.error;
303
+ if (!failure && callOptions.schema) {
304
+ for (let retry = 0;; retry++) {
305
+ const parsed = extractJson(turn.text);
306
+ const errors = parsed === INVALID
307
+ ? ["response contained no parseable JSON"]
308
+ : validateAgainstSchema(parsed, callOptions.schema);
309
+ if (errors.length === 0) {
310
+ result = parsed;
311
+ break;
312
+ }
313
+ if (retry >= 2) {
314
+ failure = `structured output failed after ${retry + 1} attempts: ${errors.join("; ")}`;
315
+ break;
316
+ }
317
+ turn = await session.promptAgain(structuredOutputRetryPrompt(errors, callOptions.schema));
318
+ if (turn.error) {
319
+ failure = turn.error;
320
+ break;
321
+ }
184
322
  }
185
323
  }
324
+ liveSessions.delete(id);
325
+ session.close();
326
+ outputTokens += turn.usage?.outputTokens ?? 0;
327
+ if (!failure ||
328
+ attempt >= AGENT_MAX_ATTEMPTS ||
329
+ turn.stopReason === "timeout" ||
330
+ turn.stopReason === "cancelled" ||
331
+ !TRANSIENT_AGENT_ERROR.test(failure)) {
332
+ break;
333
+ }
334
+ const delayMs = AGENT_RETRY_BASE_MS * 4 ** (attempt - 1);
335
+ journal.append({ type: "agent_retry", id, key, label, phase, attempt, error: failure, delayMs });
336
+ reporter.log(`↻ agent #${id} transient error, retry ${attempt}/${AGENT_MAX_ATTEMPTS - 1} in ${delayMs / 1000}s: ${failure.split("\n")[0]}`);
337
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
338
+ if (skipped.has(id))
339
+ break;
186
340
  }
187
- session.close();
341
+ const worktree = await finishCreatedWorktree();
188
342
  const seconds = Math.round((Date.now() - agentStartedAt) / 1000);
189
343
  const turnTokens = turn.usage?.outputTokens ?? 0;
190
- outputTokens += turnTokens;
191
344
  if (failure) {
192
- journal.append({ type: "agent_end", id, key, label, phase, status: "error", error: failure, seconds });
345
+ journal.append({
346
+ type: "agent_end",
347
+ id,
348
+ key,
349
+ label,
350
+ phase,
351
+ status: "error",
352
+ error: failure,
353
+ seconds,
354
+ ...(worktree ? { worktree } : {}),
355
+ });
356
+ syncStatus("running");
193
357
  reporter.agentEnd(id, false, seconds, failure);
194
358
  return null;
195
359
  }
@@ -204,11 +368,19 @@ export async function runWorkflow(options) {
204
368
  toolCalls: turn.toolCalls,
205
369
  usage: turn.usage,
206
370
  result,
371
+ ...(worktree ? { worktree } : {}),
207
372
  });
373
+ syncStatus("running");
208
374
  reporter.agentEnd(id, true, seconds, turn.usage ? `${turn.toolCalls} tools · ${turnTokens} out-tok` : `${turn.toolCalls} tools`);
209
375
  return result;
210
376
  }
211
377
  finally {
378
+ if (session) {
379
+ liveSessions.delete(id);
380
+ session.close();
381
+ }
382
+ // No-op unless a throw skipped the normal finalize (guard above).
383
+ await finishCreatedWorktree().catch(() => { });
212
384
  semaphore.release();
213
385
  }
214
386
  }
@@ -219,7 +391,7 @@ export async function runWorkflow(options) {
219
391
  return Promise.all(thunks.map((thunk) => Promise.resolve()
220
392
  .then(thunk)
221
393
  .catch((error) => {
222
- const message = error instanceof Error ? error.message : String(error);
394
+ const message = errorMessage(error);
223
395
  journal.append({ type: "log", message: `parallel task failed: ${message}` });
224
396
  reporter.log(`parallel task failed: ${message}`);
225
397
  return null;
@@ -238,7 +410,7 @@ export async function runWorkflow(options) {
238
410
  previous = await stage(previous, item, index);
239
411
  }
240
412
  catch (error) {
241
- const message = error instanceof Error ? error.message : String(error);
413
+ const message = errorMessage(error);
242
414
  journal.append({ type: "log", message: `pipeline item ${index} dropped: ${message}` });
243
415
  reporter.log(`pipeline item ${index} dropped: ${message}`);
244
416
  return null;
@@ -253,14 +425,64 @@ export async function runWorkflow(options) {
253
425
  currentPhase = title;
254
426
  journal.append({ type: "phase", title });
255
427
  reporter.phase(title);
428
+ syncStatus("running");
256
429
  }
257
430
  function logFn(message) {
258
431
  const text = typeof message === "string" ? message : JSON.stringify(message);
259
432
  journal.append({ type: "log", message: text });
260
433
  reporter.log(text);
261
434
  }
435
+ const realm = createRealm((message) => logFn(message));
436
+ async function executeScript(loaded, scriptArgs, depth) {
437
+ const scriptFn = realm.compile(SCRIPT_PARAMS, loaded.body, loaded.file);
438
+ return scriptFn(agentFn, parallelFn, pipelineFn, phaseFn, logFn, scriptArgs, budget, makeWorkflowFn(loaded.file, depth));
439
+ }
440
+ function makeWorkflowFn(callerFile, depth) {
441
+ return async (nameOrPath, childArgs) => {
442
+ if (depth >= 1)
443
+ throw new Error("workflow() nesting is limited to one level");
444
+ if (typeof nameOrPath !== "string")
445
+ throw new Error("workflow() takes a workflow name or path string");
446
+ const registryDir = join(workspace, ".newton", "workflows");
447
+ const file = /\.(js|mjs)$/.test(nameOrPath) || nameOrPath.includes("/")
448
+ ? resolve(dirname(callerFile), nameOrPath)
449
+ : join(registryDir, `${nameOrPath}.js`);
450
+ if (!existsSync(file)) {
451
+ let names = [];
452
+ try {
453
+ names = readdirSync(registryDir, { withFileTypes: true })
454
+ .filter((entry) => entry.isFile() && /\.(js|mjs)$/.test(entry.name))
455
+ .map((entry) => entry.name.replace(/\.(?:js|mjs)$/, ""))
456
+ .sort();
457
+ }
458
+ catch {
459
+ names = [];
460
+ }
461
+ throw new Error(`workflow "${nameOrPath}" not found at ${file}; registered workflows: ${names.length > 0 ? names.join(", ") : "(none)"}`);
462
+ }
463
+ const child = loadWorkflow(file);
464
+ journal.append({ type: "workflow_child_start", name: child.meta.name, file: child.file, args: childArgs ?? null });
465
+ reporter.log(`▸ workflow ${child.meta.name}`);
466
+ const savedPhase = currentPhase;
467
+ try {
468
+ const result = await executeScript(child, childArgs, depth + 1);
469
+ journal.append({ type: "workflow_child_end", name: child.meta.name, status: "ok" });
470
+ reporter.log(`✔ workflow ${child.meta.name}`);
471
+ return result;
472
+ }
473
+ catch (error) {
474
+ const message = errorMessage(error);
475
+ journal.append({ type: "workflow_child_end", name: child.meta.name, status: "error", error: message });
476
+ reporter.log(`✖ workflow ${child.meta.name}: ${message}`);
477
+ throw error;
478
+ }
479
+ finally {
480
+ currentPhase = savedPhase;
481
+ }
482
+ };
483
+ }
262
484
  try {
263
- const result = await scriptFn(agentFn, parallelFn, pipelineFn, phaseFn, logFn, options.args, budget, guardedDate, guardedMath);
485
+ const result = await executeScript(workflow, options.args, 0);
264
486
  const runResult = {
265
487
  runId: journal.runId,
266
488
  status: "ok",
@@ -270,11 +492,13 @@ export async function runWorkflow(options) {
270
492
  durationMs: Date.now() - startedAt,
271
493
  };
272
494
  journal.append({ type: "workflow_end", status: "ok", result: runResult.result, agents: agentCounter, outputTokens });
495
+ syncStatus("ok", { result: runResult.result });
273
496
  return runResult;
274
497
  }
275
498
  catch (error) {
276
- const message = error instanceof Error ? error.message : String(error);
499
+ const message = errorMessage(error);
277
500
  journal.append({ type: "workflow_end", status: "error", error: message, agents: agentCounter, outputTokens });
501
+ syncStatus("error", { error: message });
278
502
  return {
279
503
  runId: journal.runId,
280
504
  status: "error",
@@ -284,4 +508,7 @@ export async function runWorkflow(options) {
284
508
  durationMs: Date.now() - startedAt,
285
509
  };
286
510
  }
511
+ finally {
512
+ clearInterval(controlTimer);
513
+ }
287
514
  }
package/dist/journal.js CHANGED
@@ -27,7 +27,7 @@ export class RunJournal {
27
27
  };
28
28
  }
29
29
  }
30
- function newRunId() {
30
+ export function newRunId() {
31
31
  const now = new Date();
32
32
  const stamp = now
33
33
  .toISOString()
@@ -43,8 +43,14 @@ export function agentCallKey(prompt, spec, schema) {
43
43
  .slice(0, 32);
44
44
  }
45
45
  /**
46
- * Resume cache: successful agent results from a prior run, keyed by
47
- * (prompt, spec, schema) hash. Duplicate keys replay in call order.
46
+ * Resume has two modes:
47
+ * - prefix: load prior calls by positional id via loadResumeCalls.
48
+ * - hash: load successful results by (prompt, spec, schema) hash here.
49
+ *
50
+ * Invariant: journal `id` is the positional sequence. Agent ids increment at
51
+ * every agent() call, including cached calls.
52
+ *
53
+ * Duplicate hash keys replay in call order.
48
54
  */
49
55
  export function loadResumeCache(workspace, runId) {
50
56
  const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
@@ -70,3 +76,34 @@ export function loadResumeCache(workspace, runId) {
70
76
  }
71
77
  return cache;
72
78
  }
79
+ export function loadResumeCalls(workspace, runId) {
80
+ const journalPath = join(workspace, ".newton", "runs", runId, "journal.jsonl");
81
+ if (!existsSync(journalPath)) {
82
+ throw new Error(`No journal found for run "${runId}" (looked at ${journalPath})`);
83
+ }
84
+ const calls = new Map();
85
+ for (const line of readFileSync(journalPath, "utf8").split("\n")) {
86
+ if (!line.trim())
87
+ continue;
88
+ let entry;
89
+ try {
90
+ entry = JSON.parse(line);
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ const id = entry.id;
96
+ const key = entry.key;
97
+ if (typeof id !== "number" || !Number.isFinite(id) || typeof key !== "string")
98
+ continue;
99
+ if (entry.type === "agent_start") {
100
+ if (!calls.has(id)) {
101
+ calls.set(id, { key, ok: false });
102
+ }
103
+ }
104
+ else if (entry.type === "agent_end") {
105
+ calls.set(id, { key, ok: entry.status === "ok", result: entry.result });
106
+ }
107
+ }
108
+ return calls;
109
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,102 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function errorMessage(error) {
7
+ return isRecord(error) && typeof error.message === "string" ? error.message : String(error);
8
+ }
9
+ function readMcpConfig(workspace) {
10
+ const path = join(workspace, ".mcp.json");
11
+ let parsed;
12
+ try {
13
+ parsed = JSON.parse(readFileSync(path, "utf8"));
14
+ }
15
+ catch (error) {
16
+ if (isRecord(error) && error.code === "ENOENT") {
17
+ throw new Error(`MCP config file not found at ${path}`);
18
+ }
19
+ throw new Error(`Could not read ${path}: ${errorMessage(error)}`);
20
+ }
21
+ if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) {
22
+ throw new Error(`${path}: expected { "mcpServers": { ... } }`);
23
+ }
24
+ return { path, servers: parsed.mcpServers };
25
+ }
26
+ function availableNames(servers) {
27
+ const names = Object.keys(servers).sort();
28
+ return names.length > 0 ? names.join(", ") : "(none)";
29
+ }
30
+ function optionalStringArray(value, field) {
31
+ if (value === undefined || value === null)
32
+ return [];
33
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
34
+ throw new Error(`${field} must be an array of strings`);
35
+ }
36
+ return value;
37
+ }
38
+ function nameValueEntries(value, field) {
39
+ if (value === undefined || value === null)
40
+ return [];
41
+ if (!isRecord(value))
42
+ throw new Error(`${field} must be an object`);
43
+ return Object.entries(value).map(([name, entryValue]) => ({ name, value: String(entryValue) }));
44
+ }
45
+ function toMcpServer(name, value, source) {
46
+ if (!isRecord(value))
47
+ throw new Error(`${source} must be an object`);
48
+ if (value.type !== undefined) {
49
+ if (value.type !== "http" && value.type !== "sse") {
50
+ throw new Error(`${source} type must be "http" or "sse"`);
51
+ }
52
+ if (typeof value.url !== "string" || !value.url) {
53
+ throw new Error(`${source} missing url`);
54
+ }
55
+ return {
56
+ type: value.type,
57
+ name,
58
+ url: value.url,
59
+ headers: nameValueEntries(value.headers, `${source} headers`),
60
+ };
61
+ }
62
+ if (typeof value.command !== "string" || !value.command) {
63
+ throw new Error(`${source} missing command`);
64
+ }
65
+ return {
66
+ name,
67
+ command: value.command,
68
+ args: optionalStringArray(value.args, `${source} args`),
69
+ env: nameValueEntries(value.env, `${source} env`),
70
+ };
71
+ }
72
+ export function resolveMcpServers(workspace, opt) {
73
+ if (opt === true) {
74
+ const config = readMcpConfig(workspace);
75
+ return Object.entries(config.servers).map(([name, server]) => toMcpServer(name, server, `MCP server "${name}"`));
76
+ }
77
+ if (!Array.isArray(opt)) {
78
+ throw new Error("agent() mcp option takes true or an array of names/servers");
79
+ }
80
+ let config;
81
+ const getConfig = () => {
82
+ config ??= readMcpConfig(workspace);
83
+ return config;
84
+ };
85
+ return opt.map((entry) => {
86
+ if (typeof entry === "string") {
87
+ const loaded = getConfig();
88
+ const server = loaded.servers[entry];
89
+ if (server === undefined) {
90
+ throw new Error(`MCP server "${entry}" not found in ${loaded.path}; available: ${availableNames(loaded.servers)}`);
91
+ }
92
+ return toMcpServer(entry, server, `MCP server "${entry}"`);
93
+ }
94
+ if (!isRecord(entry)) {
95
+ throw new Error("agent() mcp option takes true or an array of names/servers");
96
+ }
97
+ if (typeof entry.name !== "string" || !entry.name.trim()) {
98
+ throw new Error("inline MCP server missing name");
99
+ }
100
+ return toMcpServer(entry.name, entry, `inline MCP server "${entry.name}"`);
101
+ });
102
+ }