@robzilla1738/agentswarm 0.6.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.
package/dist/agent.js CHANGED
@@ -59,20 +59,10 @@ async function runAgent(p) {
59
59
  if (stopReason)
60
60
  break;
61
61
  steps++;
62
- let res;
63
- try {
64
- res = await callModel();
65
- }
66
- catch (e) {
67
- // The chat client already retries 429/5xx; this catches the rest of the
68
- // transient class (connection resets, DNS blips) once per step so a
69
- // single network hiccup doesn't burn a whole task attempt.
70
- if (p.signal.aborted)
71
- throw e;
72
- hooks.onLog?.("warn", `${p.agentId}: model call failed (${(0, util_1.errMsg)(e)}); retrying once`);
73
- await new Promise((r) => setTimeout(r, 1500));
74
- res = await callModel();
75
- }
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();
76
66
  hooks.onUsage?.(p.model, res.usage);
77
67
  usage = (0, types_1.addUsage)(usage, res.usage);
78
68
  if (res.toolCalls.length === 0) {
package/dist/cli.js CHANGED
@@ -524,7 +524,16 @@ 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 = /apikey|token|secret/i.test(key) ? (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
  }
@@ -533,7 +542,7 @@ async function cmdConfig(rest, flags) {
533
542
  let v = cfg[k];
534
543
  // Every secret-bearing key prints masked — `config list` output ends up
535
544
  // in terminal scrollback and pasted bug reports.
536
- if (/apikey|token|secret/i.test(k)) {
545
+ if ((0, config_1.isSecretConfigKey)(k)) {
537
546
  v = v ? (0, config_1.maskKey)(String(v)) : k === "apiKey" ? util_1.ansi.red("(not set)") : "(not set)";
538
547
  }
539
548
  console.log(` ${k.padEnd(18)} ${util_1.ansi.gray(String(v))}`);
@@ -566,14 +575,15 @@ async function cmdConfig(rest, flags) {
566
575
  const key = rest[1];
567
576
  if (!key)
568
577
  throw new Error("usage: swarm config unset <key>");
569
- // Only string-valued keys can sensibly clear to "" — numbers/enums keep
570
- // their defaults via `set`.
571
- const clearable = config_1.SETTABLE_KEYS.filter((k) => /apikey|token|secret|url|model/i.test(k));
572
- if (!clearable.includes(key)) {
573
- throw new Error(`not clearable. Clearable keys: ${clearable.join(", ")}`);
578
+ if (!config_1.SETTABLE_KEYS.includes(key)) {
579
+ throw new Error(`unknown key. Keys: ${config_1.SETTABLE_KEYS.join(", ")}`);
574
580
  }
575
- (0, config_1.saveConfig)({ [key]: "" });
576
- console.log(util_1.ansi.green("✓ ") + `cleared ${key}`);
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")));
577
587
  return;
578
588
  }
579
589
  if (sub === "path") {
package/dist/config.js CHANGED
@@ -41,6 +41,7 @@ exports.runDir = runDir;
41
41
  exports.configPath = configPath;
42
42
  exports.loadConfig = loadConfig;
43
43
  exports.saveConfig = saveConfig;
44
+ exports.isSecretConfigKey = isSecretConfigKey;
44
45
  exports.maskKey = maskKey;
45
46
  exports.coerceConfigValue = coerceConfigValue;
46
47
  const fs = __importStar(require("fs"));
@@ -192,6 +193,13 @@ function loadConfig() {
192
193
  apiKey: cred.apiKey || "",
193
194
  baseUrl: cred.baseUrl || info.baseUrl,
194
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;
195
203
  // Env overrides: provider-specific key env, plus legacy DEEPSEEK_API_KEY.
196
204
  if (info.keyEnv && process.env[info.keyEnv])
197
205
  cfg.apiKey = process.env[info.keyEnv];
@@ -249,6 +257,15 @@ function saveConfig(patch) {
249
257
  }
250
258
  return loadConfig();
251
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
+ }
252
269
  function maskKey(key) {
253
270
  if (!key)
254
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
- }
package/dist/executor.js CHANGED
@@ -69,10 +69,9 @@ class Executor {
69
69
  finishNotes = "";
70
70
  finishReason = "";
71
71
  fatal = null;
72
+ /** "error" = the turn ended in a call failure, not a decision. */
72
73
  lastConductorAction = "none";
73
74
  conductorFailures = 0;
74
- /** True when the last conductor turn ended in a call error, not a decision. */
75
- lastConductorErrored = false;
76
75
  resumed = false;
77
76
  sandbox;
78
77
  mode;
@@ -138,8 +137,10 @@ class Executor {
138
137
  // must not resurrect across a restart.
139
138
  const settled = new Set(state.taskList().filter((t) => ["done", "failed", "blocked"].includes(t.status) && !reset.has(t.id)).map((t) => t.id));
140
139
  this.notes = state.notes
141
- .map((n) => ({ taskId: n.taskId, key: n.key, kind: n.kind, text: n.text, url: n.url }))
142
- .filter((n) => !(n.kind === "claim" && n.taskId && settled.has(n.taskId)));
140
+ .map((n) => ({ taskId: n.taskId, teamId: n.teamId, key: n.key, kind: n.kind, text: n.text, url: n.url }))
141
+ // Team claims always drop: the owning child executor died with the
142
+ // crash, and a re-run team task re-claims from scratch.
143
+ .filter((n) => !(n.kind === "claim" && (n.teamId || (n.taskId && settled.has(n.taskId)))));
143
144
  const lastPhase = state.phases[state.phases.length - 1];
144
145
  if (lastPhase)
145
146
  this.phase = { name: lastPhase.name, goal: lastPhase.goal, exitCriteria: lastPhase.exitCriteria };
@@ -398,6 +399,13 @@ class Executor {
398
399
  sharedNotes: this.notes,
399
400
  });
400
401
  await child.run();
402
+ // The sub-swarm is over: claims its tasks left behind (e.g. after a child
403
+ // cancellation) are no longer live and must not haunt the shared board.
404
+ for (let i = this.notes.length - 1; i >= 0; i--) {
405
+ const n = this.notes[i];
406
+ if (n.kind === "claim" && n.teamId === task.id)
407
+ this.notes.splice(i, 1);
408
+ }
401
409
  if (this.ac.signal.aborted) {
402
410
  this.finalizeTask(task, "failed", "run cancelled");
403
411
  return;
@@ -406,11 +414,13 @@ class Executor {
406
414
  for (const a of child.teamArtifacts())
407
415
  if (!task.artifacts.includes(a))
408
416
  task.artifacts.push(a);
417
+ const ok = child.anyTaskDone();
418
+ const reportStatus = ok ? "done" : "blocked";
409
419
  task.report = report;
410
- task.reportStatus = "done";
420
+ task.reportStatus = reportStatus;
411
421
  this.journal.append("team.report", { taskId: task.id, report, artifacts: task.artifacts });
412
- this.journal.append("task.report", { taskId: task.id, status: "done", report, artifacts: task.artifacts });
413
- this.finalizeTask(task, child.anyTaskDone() ? "done" : "failed", report);
422
+ this.journal.append("task.report", { taskId: task.id, status: reportStatus, report, artifacts: task.artifacts });
423
+ this.finalizeTask(task, ok ? "done" : "failed", report);
414
424
  }
415
425
  async mainLoop() {
416
426
  while (!this.finishing) {
@@ -446,7 +456,7 @@ class Executor {
446
456
  // An errored turn is not a decision — keep looping so the breaker
447
457
  // can retry (and eventually trip) instead of misreading the error
448
458
  // as "the conductor chose to stop".
449
- if (this.lastConductorAction !== "spawn" && !this.lastConductorErrored) {
459
+ if (this.lastConductorAction !== "spawn" && this.lastConductorAction !== "error") {
450
460
  this.finishing = true;
451
461
  this.finishReason = this.finishReason || "all tasks settled";
452
462
  }
@@ -455,7 +465,7 @@ class Executor {
455
465
  // Stuck: pending tasks exist but can't run (failed/blocked deps).
456
466
  this.appendConductorUpdate("Some tasks cannot run because their dependencies failed or were blocked. Re-plan around them or finish.", reports);
457
467
  await this.conductorTurn();
458
- if (this.lastConductorAction === "wait" && !this.lastConductorErrored) {
468
+ if (this.lastConductorAction === "wait") {
459
469
  this.finishing = true;
460
470
  this.finishReason = "stalled: dependencies unmet and conductor chose to wait";
461
471
  }
@@ -596,12 +606,10 @@ class Executor {
596
606
  const scale = Number(process.env.SWARM_BACKOFF_SCALE || "1") || 1;
597
607
  const backoff = [2_000, 5_000, 15_000, 30_000][Math.min(this.conductorFailures - 1, 3)] * scale;
598
608
  await new Promise((r) => setTimeout(r, backoff));
599
- this.lastConductorAction = "wait";
600
- this.lastConductorErrored = true;
609
+ this.lastConductorAction = "error";
601
610
  return;
602
611
  }
603
612
  this.conductorFailures = 0;
604
- this.lastConductorErrored = false;
605
613
  this.onUsage(this.meta.options.conductorModel, res.usage);
606
614
  if (res.content.trim())
607
615
  this.journal.append("conductor.say", { text: (0, util_1.clip)(res.content, 4000) });
@@ -783,7 +791,8 @@ class Executor {
783
791
  return reports.map(prompts_1.reportBlock);
784
792
  const important = reports.filter((t) => t.status !== "done");
785
793
  const done = reports.filter((t) => t.status === "done");
786
- const fullDone = done.slice(-Math.max(0, CAP - important.length));
794
+ const room = Math.max(0, CAP - important.length);
795
+ const fullDone = room > 0 ? done.slice(-room) : []; // slice(-0) would return everything
787
796
  const briefDone = done.slice(0, done.length - fullDone.length);
788
797
  return [
789
798
  ...important.map(prompts_1.reportBlock),
@@ -1012,7 +1021,7 @@ class Executor {
1012
1021
  signal: this.ac.signal,
1013
1022
  addCheckpoint: task ? (summary) => this.recordCheckpoint(task, agentId, summary) : undefined,
1014
1023
  addNote: (text, key, kind, url) => {
1015
- this.notes.push({ taskId: task?.id, key, kind, text, url });
1024
+ this.notes.push({ taskId: task?.id, teamId: this.teamId, key, kind, text, url });
1016
1025
  // Only the recent tail ever feeds digests; without a cap a multi-day
1017
1026
  // run accumulates every note in memory. Decisions and conflicts are
1018
1027
  // kept regardless. In-place splice: teams share this array by reference.
@@ -1035,11 +1044,16 @@ class Executor {
1035
1044
  readReport: (taskId) => this.readReportText(taskId),
1036
1045
  checkClaim: (rel) => {
1037
1046
  const norm = rel.replace(/^\.\//, "");
1038
- const claim = this.notes.find((n) => n.kind === "claim" &&
1039
- n.key === norm &&
1040
- n.taskId &&
1041
- n.taskId !== task?.id &&
1042
- ["running", "verifying"].includes(this.tasks.get(n.taskId)?.status ?? ""));
1047
+ const claim = this.notes.find((n) => {
1048
+ if (n.kind !== "claim" || n.key !== norm || !n.taskId)
1049
+ return false;
1050
+ // Another executor's claim: its tasks aren't in this.tasks, but
1051
+ // claims are spliced out when their task settles (and when a team
1052
+ // ends), so presence alone means the holder is still live.
1053
+ if (n.teamId !== this.teamId)
1054
+ return true;
1055
+ return n.taskId !== task?.id && ["running", "verifying"].includes(this.tasks.get(n.taskId)?.status ?? "");
1056
+ });
1043
1057
  return claim
1044
1058
  ? `⚠ ${claim.taskId} holds a claim on ${norm} ("${(0, util_1.oneLine)(claim.text, 80)}") — coordinate via the blackboard before further edits.`
1045
1059
  : null;
@@ -1401,7 +1415,7 @@ class Executor {
1401
1415
  // teams share this array by reference.
1402
1416
  for (let i = this.notes.length - 1; i >= 0; i--) {
1403
1417
  const n = this.notes[i];
1404
- if (n.kind === "claim" && n.taskId === task.id)
1418
+ if (n.kind === "claim" && n.taskId === task.id && n.teamId === this.teamId)
1405
1419
  this.notes.splice(i, 1);
1406
1420
  }
1407
1421
  this.journal.append("task.status", { taskId: task.id, status, attempt: task.attempt, reason });
@@ -1508,7 +1522,9 @@ class Executor {
1508
1522
  queueDelta(agentId, taskId, channel, text) {
1509
1523
  // Deltas are UI sugar, never state — thin them under load so a 100-agent
1510
1524
  // swarm doesn't write gigabytes of streaming chatter into the journal.
1511
- const load = this.activeWorkerCount();
1525
+ // inflight.size over-counts verifying tasks slightly, but these are fuzzy
1526
+ // thresholds and this runs per streaming token — O(1) matters here.
1527
+ const load = this.inflight.size;
1512
1528
  if (channel === "think" && load > 48) {
1513
1529
  if (!this.thinkDropLogged) {
1514
1530
  this.thinkDropLogged = true;
package/dist/hub.js CHANGED
@@ -180,9 +180,7 @@ async function api(req, res, url, opts) {
180
180
  return { engine, ok: false, detail: (0, util_1.errMsg)(e) };
181
181
  }
182
182
  };
183
- const checks = [probe("duckduckgo", () => (0, webtools_1.ddgSearch)(q, 3)), probe("bing", () => (0, webtools_1.bingSearch)(q, 3))];
184
- if (cfg.tinyfishApiKey)
185
- checks.push(probe("tinyfish", () => (0, webtools_1.tinyfishSearch)(cfg, q, 3)));
183
+ const checks = (0, webtools_1.searchEngines)(cfg).map((e) => probe(e.name, () => e.search(q, 3)));
186
184
  const engines = await Promise.all(checks);
187
185
  return sendJson(res, 200, { ok: engines.some((e) => e.ok), engines });
188
186
  }
package/dist/journal.js CHANGED
@@ -88,11 +88,14 @@ class Journal {
88
88
  }
89
89
  return ev;
90
90
  }
91
+ /** The chunk an async drain is writing right now — flushSync must see it. */
92
+ inFlight = "";
91
93
  async drain() {
92
94
  if (!this.buf)
93
95
  return;
94
96
  const chunk = this.buf;
95
97
  this.buf = "";
98
+ this.inFlight = chunk;
96
99
  try {
97
100
  await fs.promises.appendFile(this.file, chunk, "utf8");
98
101
  this.failures = 0;
@@ -107,16 +110,26 @@ class Journal {
107
110
  process.stderr.write(`agentswarm: journal writes are failing (${String(e)}); run state is no longer durable\n`);
108
111
  }
109
112
  }
113
+ finally {
114
+ this.inFlight = "";
115
+ }
110
116
  }
111
117
  flush() {
112
118
  return this.chain.then(() => this.drain());
113
119
  }
114
- /** Last-gasp synchronous flush for signal handlers and exit paths. */
120
+ /**
121
+ * Last-gasp synchronous flush for signal handlers and exit paths. Includes
122
+ * any chunk a pending async drain holds: process.exit would abandon that
123
+ * write, silently losing just-settled events. If the abandoned write did
124
+ * land first, the chunk appears twice — readers dedupe by seq.
125
+ */
115
126
  flushSync() {
116
- if (!this.buf)
127
+ const pending = this.inFlight + this.buf;
128
+ if (!pending)
117
129
  return;
118
130
  try {
119
- fs.appendFileSync(this.file, this.buf, "utf8");
131
+ fs.appendFileSync(this.file, pending, "utf8");
132
+ this.inFlight = "";
120
133
  this.buf = "";
121
134
  }
122
135
  catch {
@@ -136,7 +149,24 @@ function readEvents(runDirPath) {
136
149
  catch {
137
150
  return [];
138
151
  }
139
- return parseLines(raw).events;
152
+ return dedupeBySeq(parseLines(raw).events);
153
+ }
154
+ /**
155
+ * Seq is strictly increasing in a healthy journal; a chunk can appear twice
156
+ * when a signal-handler flushSync raced an in-flight async append. Replays of
157
+ * already-seen seqs are dropped.
158
+ */
159
+ function dedupeBySeq(events, lastSeq = 0) {
160
+ let max = lastSeq;
161
+ const out = [];
162
+ for (const ev of events) {
163
+ if (typeof ev.seq === "number" && ev.seq <= max)
164
+ continue;
165
+ if (typeof ev.seq === "number")
166
+ max = ev.seq;
167
+ out.push(ev);
168
+ }
169
+ return out;
140
170
  }
141
171
  function lastSeq(runDirPath) {
142
172
  const evs = readEvents(runDirPath);
@@ -155,6 +185,7 @@ function readNewEvents(file, state) {
155
185
  // Truncated/rewritten (should not happen) — start over.
156
186
  state.offset = 0;
157
187
  state.carry = "";
188
+ state.lastSeq = 0;
158
189
  }
159
190
  if (stat.size === state.offset)
160
191
  return [];
@@ -172,7 +203,10 @@ function readNewEvents(file, state) {
172
203
  const text = state.carry + buf.toString("utf8", 0, n);
173
204
  const parsed = parseLines(text, true);
174
205
  state.carry = parsed.carry;
175
- out.push(...parsed.events);
206
+ const fresh = dedupeBySeq(parsed.events, state.lastSeq ?? 0);
207
+ if (fresh.length)
208
+ state.lastSeq = fresh[fresh.length - 1].seq;
209
+ out.push(...fresh);
176
210
  }
177
211
  state.offset += read;
178
212
  return out;
package/dist/memory.js CHANGED
@@ -38,7 +38,6 @@ exports.loadMemory = loadMemory;
38
38
  exports.appendMemory = appendMemory;
39
39
  exports.memoryBlock = memoryBlock;
40
40
  const crypto = __importStar(require("crypto"));
41
- const fs = __importStar(require("fs"));
42
41
  const path = __importStar(require("path"));
43
42
  const config_1 = require("./config");
44
43
  const util_1 = require("./util");
@@ -48,13 +47,19 @@ function memoryFile(cwd) {
48
47
  return path.join((0, config_1.home)(), "memory", `${hash}.json`);
49
48
  }
50
49
  function loadMemory(cwd) {
51
- try {
52
- const raw = JSON.parse(fs.readFileSync(memoryFile(cwd), "utf8"));
53
- return Array.isArray(raw?.entries) ? raw.entries : [];
54
- }
55
- catch {
50
+ const raw = (0, util_1.readJson)(memoryFile(cwd), {});
51
+ if (!Array.isArray(raw.entries))
56
52
  return [];
57
- }
53
+ // Memory is best-effort and the file is user-editable: one malformed entry
54
+ // must degrade to "forgotten", never crash a run at startup.
55
+ return raw.entries.filter((e) => !!e &&
56
+ typeof e === "object" &&
57
+ typeof e.mission === "string" &&
58
+ typeof e.summary === "string" &&
59
+ typeof e.status === "string" &&
60
+ Number.isFinite(e.finishedAt) &&
61
+ Array.isArray(e.keyDecisions) &&
62
+ e.keyDecisions.every((d) => typeof d === "string"));
58
63
  }
59
64
  function appendMemory(cwd, entry) {
60
65
  try {
package/dist/report.js CHANGED
@@ -16,6 +16,7 @@ exports.sourcesBlock = sourcesBlock;
16
16
  exports.mdToHtml = mdToHtml;
17
17
  exports.renderFinalHtml = renderFinalHtml;
18
18
  const searchcore_1 = require("./searchcore");
19
+ const util_1 = require("./util");
19
20
  /**
20
21
  * Dedupe every task's reported sources (by canonical URL) into one numbered
21
22
  * bibliography for the synthesizer. First occurrence wins the number; later
@@ -278,7 +279,7 @@ function renderFinalHtml(o) {
278
279
  <span class="badge ${o.status}">${o.status}</span>
279
280
  <span>run ${esc(o.runId)}</span>
280
281
  <span>${esc(date)}</span>
281
- <span title="${esc(o.mission.slice(0, 600))}">mission: ${esc(o.mission.length > 90 ? o.mission.slice(0, 90) + "…" : o.mission)}</span>
282
+ <span title="${esc(o.mission.slice(0, 600))}">mission: ${esc((0, util_1.clip)(o.mission, 90))}</span>
282
283
  </header>
283
284
  <main>
284
285
  ${mdToHtml(o.markdown)}
package/dist/state.js CHANGED
@@ -59,6 +59,12 @@ class RunState {
59
59
  this.cost += (0, types_1.usageCost)(u, this.pricing[model]);
60
60
  this.pushBudgetPoint(ev.t);
61
61
  }
62
+ else if (ev.type === "note.added") {
63
+ // The blackboard is shared swarm-wide at runtime, so team notes are
64
+ // root facts too — without this, a resume would forget every note a
65
+ // team agent posted (decisions included).
66
+ this.pushNote(ev, teamId);
67
+ }
62
68
  return;
63
69
  }
64
70
  switch (ev.type) {
@@ -200,25 +206,7 @@ class RunState {
200
206
  });
201
207
  break;
202
208
  case "note.added":
203
- this.notes.push({
204
- t: ev.t,
205
- taskId: ev.taskId,
206
- agentId: ev.agentId,
207
- key: ev.key,
208
- kind: ev.kind,
209
- text: ev.text,
210
- url: typeof ev.url === "string" ? ev.url : undefined,
211
- });
212
- // Reduced state is held live by the hub and the resume seed — keep
213
- // only the tail that digests/views actually use. Decisions and
214
- // conflicts are never dropped: they anchor long-horizon coherence.
215
- if (this.notes.length > 1000) {
216
- const keep = (n) => n.kind === "decision" || n.kind === "conflict";
217
- const pinned = this.notes.filter(keep);
218
- const rest = this.notes.filter((n) => !keep(n));
219
- rest.splice(0, rest.length - Math.max(0, 1000 - pinned.length));
220
- this.notes = [...pinned, ...rest].sort((a, b) => a.t - b.t);
221
- }
209
+ this.pushNote(ev);
222
210
  break;
223
211
  case "conductor.say":
224
212
  this.conductorLog.push({ t: ev.t, text: ev.text });
@@ -269,6 +257,40 @@ class RunState {
269
257
  this.budgetSeries = this.budgetSeries.filter((_, i) => i % 2 === 0 || i === this.budgetSeries.length - 1);
270
258
  }
271
259
  }
260
+ pushNote(ev, teamId) {
261
+ this.notes.push({
262
+ t: ev.t,
263
+ taskId: ev.taskId,
264
+ teamId,
265
+ agentId: ev.agentId,
266
+ key: ev.key,
267
+ kind: ev.kind,
268
+ text: ev.text,
269
+ url: typeof ev.url === "string" ? ev.url : undefined,
270
+ });
271
+ // Reduced state is held live by the hub and the resume seed — keep only
272
+ // the tail that digests/views actually use. Decisions and conflicts are
273
+ // never dropped: they anchor long-horizon coherence. Forward-pass splice
274
+ // (mirroring the executor's addNote): the array is permanently at the cap
275
+ // once a long run passes it, so this runs on every note event — no
276
+ // filter/sort allocations on the reducer hot path.
277
+ if (this.notes.length > 1000) {
278
+ const keep = (n) => n.kind === "decision" || n.kind === "conflict";
279
+ let pinnedCount = 0;
280
+ for (const n of this.notes)
281
+ if (keep(n))
282
+ pinnedCount++;
283
+ let toDrop = this.notes.length - Math.max(pinnedCount, 1000);
284
+ for (let i = 0; i < this.notes.length && toDrop > 0;) {
285
+ if (!keep(this.notes[i])) {
286
+ this.notes.splice(i, 1);
287
+ toDrop--;
288
+ }
289
+ else
290
+ i++;
291
+ }
292
+ }
293
+ }
272
294
  taskList() {
273
295
  return this.taskOrder.map((id) => this.tasks.get(id)).filter(Boolean);
274
296
  }
package/dist/tools.js CHANGED
@@ -301,9 +301,21 @@ function workerToolset(cfg) {
301
301
  const excludes = ["node_modules", ".git", "dist", ".next", "out", "build", "target", "__pycache__", ".venv"]
302
302
  .map((d) => ` --exclude-dir=${d}`)
303
303
  .join("");
304
- const cmd = `grep ${flags}${include}${excludes} -e ${shq(pattern)} ${shq(root)} | head -n ${max + 1}`;
304
+ // No `| head`: a pipe would mask grep's exit code, and an invalid regex
305
+ // or unreadable path must fail loudly, not read as "no matches".
306
+ // (Output volume is already bounded by the sandbox's collect cap.)
307
+ const cmd = `grep ${flags}${include}${excludes} -e ${shq(pattern)} ${shq(root)}`;
305
308
  const r = await ctx.sandbox.exec(cmd, { cwd: ctx.workdir, timeoutSec: 60, signal: ctx.signal });
306
- const lines = r.out.split("\n").filter(Boolean);
309
+ // Sandbox exec merges stderr into out — separate grep's diagnostics.
310
+ const all = r.out.split("\n").filter(Boolean);
311
+ const diags = all.filter((l) => l.startsWith("grep:"));
312
+ const lines = all.filter((l) => !l.startsWith("grep:"));
313
+ // Exit 1 = clean no-match. Anything past 1 with zero matches is a real
314
+ // failure (bad pattern, missing path); with matches it's partial
315
+ // (some files unreadable) and the matches still count.
316
+ if (r.code !== 0 && r.code !== 1 && !lines.length) {
317
+ throw new Error(`grep failed (exit ${r.code}): ${diags.join("; ").slice(0, 300) || "no error detail"}`);
318
+ }
307
319
  if (!lines.length)
308
320
  return "no matches";
309
321
  const shown = lines.slice(0, max);
package/dist/util.js CHANGED
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ansi = exports.sleep = void 0;
36
+ exports.ansi = void 0;
37
37
  exports.rid = rid;
38
+ exports.sleep = sleep;
39
+ exports.mergeSignal = mergeSignal;
38
40
  exports.truncateMiddle = truncateMiddle;
39
41
  exports.clip = clip;
40
42
  exports.oneLine = oneLine;
@@ -62,8 +64,35 @@ function rid(prefix) {
62
64
  const c = ridCounter.toString(36).padStart(2, "0");
63
65
  return `${prefix}_${t}${r}${c}`;
64
66
  }
65
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
66
- exports.sleep = sleep;
67
+ /** Resolves after ms; rejects early if the signal aborts first. */
68
+ function sleep(ms, signal) {
69
+ return new Promise((resolve, reject) => {
70
+ if (!signal) {
71
+ setTimeout(resolve, ms);
72
+ return;
73
+ }
74
+ if (signal.aborted) {
75
+ reject(new Error("aborted"));
76
+ return;
77
+ }
78
+ const t = setTimeout(() => {
79
+ signal.removeEventListener("abort", onAbort);
80
+ resolve();
81
+ }, ms);
82
+ const onAbort = () => {
83
+ clearTimeout(t);
84
+ reject(new Error("aborted"));
85
+ };
86
+ signal.addEventListener("abort", onAbort, { once: true });
87
+ });
88
+ }
89
+ /** A timeout signal, combined with the caller's signal when one is given. */
90
+ function mergeSignal(timeoutMs, signal) {
91
+ const t = AbortSignal.timeout(timeoutMs);
92
+ if (!signal)
93
+ return t;
94
+ return typeof AbortSignal.any === "function" ? AbortSignal.any([t, signal]) : signal;
95
+ }
67
96
  // ---------- strings ----------
68
97
  function truncateMiddle(s, max, label = "bytes") {
69
98
  if (s.length <= max)