@evomap/evolver 1.87.2 → 1.87.3

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 (65) hide show
  1. package/README.ja-JP.md +1 -1
  2. package/README.ko-KR.md +1 -1
  3. package/README.md +9 -8
  4. package/README.zh-CN.md +9 -8
  5. package/package.json +1 -1
  6. package/scripts/build_binaries.js +31 -7
  7. package/src/atp/atpExecute.js +35 -8
  8. package/src/atp/autoBuyer.js +71 -16
  9. package/src/atp/autoDeliver.js +16 -0
  10. package/src/atp/cliAutobuyPrompt.js +8 -22
  11. package/src/atp/hubClient.js +42 -4
  12. package/src/evolve/guards.js +1 -1
  13. package/src/evolve/pipeline/collect.js +1 -1
  14. package/src/evolve/pipeline/dispatch.js +1 -1
  15. package/src/evolve/pipeline/enrich.js +1 -1
  16. package/src/evolve/pipeline/hub.js +1 -1
  17. package/src/evolve/pipeline/select.js +1 -1
  18. package/src/evolve/pipeline/signals.js +1 -1
  19. package/src/evolve/utils.js +1 -1
  20. package/src/evolve.js +1 -1
  21. package/src/gep/a2aProtocol.js +1 -1
  22. package/src/gep/assetStore.js +52 -5
  23. package/src/gep/candidateEval.js +1 -1
  24. package/src/gep/candidates.js +1 -1
  25. package/src/gep/contentHash.js +1 -1
  26. package/src/gep/crypto.js +1 -1
  27. package/src/gep/curriculum.js +1 -1
  28. package/src/gep/deviceId.js +1 -1
  29. package/src/gep/envFingerprint.js +1 -1
  30. package/src/gep/epigenetics.js +1 -1
  31. package/src/gep/explore.js +1 -1
  32. package/src/gep/hash.js +1 -1
  33. package/src/gep/hubFetch.js +1 -1
  34. package/src/gep/hubReview.js +1 -1
  35. package/src/gep/hubSearch.js +1 -1
  36. package/src/gep/hubVerify.js +1 -1
  37. package/src/gep/learningSignals.js +1 -1
  38. package/src/gep/memoryGraph.js +1 -1
  39. package/src/gep/memoryGraphAdapter.js +1 -1
  40. package/src/gep/mutation.js +1 -1
  41. package/src/gep/narrativeMemory.js +1 -1
  42. package/src/gep/openPRRegistry.js +1 -1
  43. package/src/gep/paths.js +6 -2
  44. package/src/gep/personality.js +1 -1
  45. package/src/gep/policyCheck.js +1 -1
  46. package/src/gep/prompt.js +1 -1
  47. package/src/gep/recallVerifier.js +1 -1
  48. package/src/gep/reflection.js +1 -1
  49. package/src/gep/sanitize.js +57 -3
  50. package/src/gep/selector.js +1 -1
  51. package/src/gep/selfPR.js +34 -1
  52. package/src/gep/skill2gep.js +108 -29
  53. package/src/gep/skillDistiller.js +1 -1
  54. package/src/gep/solidify.js +1 -1
  55. package/src/gep/strategy.js +1 -1
  56. package/src/gep/workspaceKeychain.js +1 -1
  57. package/src/proxy/lifecycle/manager.js +97 -37
  58. package/src/proxy/router/messages_route.js +25 -0
  59. package/src/proxy/sync/engine.js +68 -31
  60. package/assets/gep/candidates.jsonl +0 -1
  61. package/assets/gep/capsules.json +0 -4
  62. package/assets/gep/events.jsonl +0 -0
  63. package/assets/gep/failed_capsules.json +0 -4
  64. package/assets/gep/genes.json +0 -245
  65. package/assets/gep/genes.jsonl +0 -0
package/README.ja-JP.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver はこの結論を実装に落とし込んだオープンソースエンジンです。GEP プロトコルの下で、エージェントの経験を場当たり的なプロンプトやスキルドキュメントではなく、Gene と Capsule として符号化します。*なぜ* Evolver が長いスキルドキュメントではなく Gene にこだわるのか疑問に思ったことがあるなら、読むべきはこの論文です。
34
34
  >
35
- > 応用事例を見たい方へ:[OpenClaw x EvoMap:CritPt 評価レポート](https://evomap.ai/blog/openclaw-critpt-report) では、OpenClaw エージェントが CritPt Physics Solver 上の 5 バージョン(Beta → v2.2)にわたって、同じ Gene ベース進化ループによってスコアを 0.00% から 18.57% まで押し上げる全過程を、トークンコストの軌跡、遺伝子活性化マップ、そして推論が再利用可能な Gene に圧縮されるときに現れる「トークンが上昇してから下降する」シグネチャとともに詳述しています。
35
+ > 応用事例を見たい方へ:[OpenClaw x EvoMap:CritPt 評価レポート](https://evomap.ai/blog/openclaw-critpt-report) では、OpenClaw エージェントが CritPt Physics Solver 上の 5 バージョン(Beta → v2.2)にわたって、同じ Gene ベース進化ループによってスコアを 9.1% から 18.57% まで押し上げる全過程を、トークンコストの軌跡、遺伝子活性化マップ、そして推論が再利用可能な Gene に圧縮されるときに現れる「トークンが上昇してから下降する」シグネチャとともに詳述しています。
36
36
 
37
37
  ---
38
38
 
package/README.ko-KR.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver는 이 결과를 실제로 구현하는 오픈소스 엔진입니다. GEP 프로토콜 아래 에이전트의 경험을 임시 프롬프트나 스킬 문서가 아니라 Gene과 Capsule로 인코딩합니다. *왜* Evolver가 더 긴 스킬 문서 대신 Gene을 고집하는지 궁금했다면, 바로 이 논문을 읽어야 합니다.
34
34
  >
35
- > 적용 사례가 궁금하신가요? [OpenClaw x EvoMap: CritPt 평가 보고서](https://evomap.ai/blog/openclaw-critpt-report)는 동일한 Gene 기반 진화 루프가 OpenClaw 에이전트를 CritPt Physics Solver의 5개 버전(Beta → v2.2)에 걸쳐 0.00%에서 18.57%까지 끌어올리는 과정을, 전체 토큰 비용 궤적, 유전자 활성화 매핑, 그리고 추론이 재사용 가능한 Gene으로 압축될 때 나타나는 "토큰이 먼저 상승한 뒤 하강하는" 시그니처와 함께 단계별로 보여줍니다.
35
+ > 적용 사례가 궁금하신가요? [OpenClaw x EvoMap: CritPt 평가 보고서](https://evomap.ai/blog/openclaw-critpt-report)는 동일한 Gene 기반 진화 루프가 OpenClaw 에이전트를 CritPt Physics Solver의 5개 버전(Beta → v2.2)에 걸쳐 9.1%에서 18.57%까지 끌어올리는 과정을, 전체 토큰 비용 궤적, 유전자 활성화 매핑, 그리고 추론이 재사용 가능한 Gene으로 압축될 때 나타나는 "토큰이 먼저 상승한 뒤 하강하는" 시그니처와 함께 단계별로 보여줍니다.
36
36
 
37
37
  ---
38
38
 
package/README.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver is the open-source engine that puts this result into practice: it encodes agent experience as Genes and Capsules under the GEP protocol, not as ad hoc prompts or skill docs. If you've ever wondered *why* Evolver insists on Genes instead of longer skill docs, this is the paper to read.
34
34
  >
35
- > Want the applied version? [OpenClaw x EvoMap: CritPt Evaluation Report](https://evomap.ai/blog/openclaw-critpt-report) walks through how the same Gene-based evolution loop drives an OpenClaw agent from 0.00% to 18.57% on CritPt Physics Solver across five versions (Beta -> v2.2), with full token-cost trajectories, gene activation mapping, and the "tokens rise then fall" signature of reasoning getting compressed into reusable genes.
35
+ > Want the applied version? [OpenClaw x EvoMap: CritPt Evaluation Report](https://evomap.ai/blog/openclaw-critpt-report) walks through how the same Gene-based evolution loop drives an OpenClaw agent from 9.1% to 18.57% on CritPt Physics Solver across five versions (Beta -> v2.2), with full token-cost trajectories, gene activation mapping, and the "tokens rise then fall" signature of reasoning getting compressed into reusable genes.
36
36
 
37
37
  ---
38
38
 
@@ -184,7 +184,7 @@ Every `evolver <flag>` invocation in the rest of this README maps 1:1 to `node i
184
184
  **Evolver is a prompt generator, not a code patcher.** Each evolution cycle:
185
185
 
186
186
  1. Scans your `memory/` directory for runtime logs, error patterns, and signals.
187
- 2. Selects the best-matching [Gene or Capsule](https://evomap.ai/wiki) from `assets/gep/`.
187
+ 2. Selects the best-matching [Gene or Capsule](https://evomap.ai/wiki) from the local GEP asset store.
188
188
  3. Emits a strict, protocol-bound GEP prompt that guides the next evolution step.
189
189
  4. Records an auditable [EvolutionEvent](https://evomap.ai/wiki) for traceability.
190
190
 
@@ -377,16 +377,17 @@ The [evomap.ai](https://evomap.ai) dashboard has a "Worker" toggle on the node d
377
377
 
378
378
  This repo includes a protocol-constrained prompt mode based on [GEP (Genome Evolution Protocol)](https://evomap.ai/wiki).
379
379
 
380
- - **Structured assets** live in `assets/gep/`:
381
- - `assets/gep/genes.json`
382
- - `assets/gep/capsules.json`
383
- - `assets/gep/events.jsonl`
380
+ - **Structured runtime assets** live in `<workspace>/.evolver/gep/` by default:
381
+ - `<workspace>/.evolver/gep/genes.json`
382
+ - `<workspace>/.evolver/gep/capsules.json`
383
+ - `<workspace>/.evolver/gep/events.jsonl`
384
+ - Set `GEP_ASSETS_DIR` to place the runtime asset store elsewhere.
384
385
  - **Selector** logic uses extracted signals to prefer existing Genes/Capsules and emits a JSON selector decision in the prompt.
385
386
  - **Constraints**: Only the DNA emoji is allowed in documentation; all other emoji are disallowed.
386
387
 
387
388
  ### Your local asset store is never overwritten by upgrades
388
389
 
389
- `assets/gep/genes.json`, `assets/gep/capsules.json`, and `assets/gep/events.jsonl` are owned by your runtime. Starting with 1.78.3, the npm tarball no longer contains these files, so `npm i -g @evomap/evolver` (or `git pull` of the public repo) never clobbers your accumulated Genes, Capsules, or EvolutionEvents. New installs still receive the curated starter Genes through `assets/gep/genes.seed.json`, which is applied only when `genes.json` is absent.
390
+ `<workspace>/.evolver/gep/genes.json`, `<workspace>/.evolver/gep/capsules.json`, and `<workspace>/.evolver/gep/events.jsonl` are owned by your runtime and ignored by git. `assets/gep/` is reserved for bundled starter assets. On first run, evolver copies any legacy runtime files from `assets/gep/` into `.evolver/gep/` without deleting the originals, then seeds `genes.json` from the bundled starter genes only when no local `genes.json` exists.
390
391
 
391
392
  If you ran an older evolver version that wiped your local assets, pull back everything you Promoted or published to the Hub with a single command:
392
393
 
@@ -396,7 +397,7 @@ A2A_HUB_URL=https://evomap.ai evolver sync --scope=all --export=backup.gepx
396
397
 
397
398
  This hits `/a2a/assets/purchased` (Promoted-to-you plus self-purchased) and `/a2a/assets/published-by-me` (your own drafts and published assets), re-materializes the full payloads into `genes.json` / `capsules.json`, and packs a portable `.gepx` bundle. Previously-purchased payloads re-fetch at zero cost.
398
399
 
399
- Purely local assets that were never uploaded to the Hub have no remote copy -- recover them from your git history (for example `git show <old_tag>:assets/gep/genes.json > restored.json`) or from disk snapshots.
400
+ Purely local assets that were never uploaded to the Hub have no remote copy -- recover them from `.evolver/gep/`, from an older `assets/gep/` checkout, or from disk snapshots.
400
401
 
401
402
  ## Configuration & Decoupling
402
403
 
package/README.zh-CN.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver 正是把这一结论落地的开源引擎:它基于 GEP 协议,把 Agent 的经验沉淀为 Gene 与 Capsule,而不是散落的 prompt 或技能文档。如果你想知道 *为什么* Evolver 坚持使用 Gene 而不是更长的 skill 文档,这就是那篇该读的论文。
34
34
  >
35
- > 想看应用落地的样本?[OpenClaw x EvoMap:CritPt 评测报告](https://evomap.ai/blog/openclaw-critpt-report) 以 OpenClaw Agent 在 CritPt Physics Solver 上的五个版本演进(Beta → v2.2)为例,完整拆解了同一套 Gene 进化闭环如何把得分从 0.00% 推到 18.57%,并给出 token 成本轨迹、基因激活映射,以及推理被压缩成可复用基因后所呈现的「token 先升后降」特征。
35
+ > 想看应用落地的样本?[OpenClaw x EvoMap:CritPt 评测报告](https://evomap.ai/blog/openclaw-critpt-report) 以 OpenClaw Agent 在 CritPt Physics Solver 上的五个版本演进(Beta → v2.2)为例,完整拆解了同一套 Gene 进化闭环如何把得分从 9.1% 推到 18.57%,并给出 token 成本轨迹、基因激活映射,以及推理被压缩成可复用基因后所呈现的「token 先升后降」特征。
36
36
 
37
37
  ---
38
38
 
@@ -159,7 +159,7 @@ evolver --loop
159
159
  **Evolver 是一个提示词生成器,不是代码修改器。** 每个进化周期:
160
160
 
161
161
  1. 扫描 `memory/` 目录中的运行日志、错误模式和信号。
162
- 2. `assets/gep/` 中选择最匹配的 [Gene 或 Capsule](https://evomap.ai/wiki)。
162
+ 2. 从本地 GEP 资产库中选择最匹配的 [Gene 或 Capsule](https://evomap.ai/wiki)。
163
163
  3. 输出一份严格的、受协议约束的 GEP 提示词来引导下一步进化。
164
164
  4. 记录可审计的 [EvolutionEvent](https://evomap.ai/wiki) 以便追溯。
165
165
 
@@ -351,16 +351,17 @@ WORKER_ENABLED=1 WORKER_DOMAINS=repair,harden WORKER_MAX_LOAD=3 evolver --loop
351
351
 
352
352
  本仓库内置基于 [GEP(基因组进化协议)](https://evomap.ai/wiki)的协议受限提示词模式。
353
353
 
354
- - **结构化资产目录**:`assets/gep/`
355
- - `assets/gep/genes.json`
356
- - `assets/gep/capsules.json`
357
- - `assets/gep/events.jsonl`
354
+ - **结构化运行时资产目录**:默认位于 `<workspace>/.evolver/gep/`
355
+ - `<workspace>/.evolver/gep/genes.json`
356
+ - `<workspace>/.evolver/gep/capsules.json`
357
+ - `<workspace>/.evolver/gep/events.jsonl`
358
+ - 可通过 `GEP_ASSETS_DIR` 把运行时资产库放到其他位置。
358
359
  - **Selector 选择器**:根据日志提取 signals,优先复用已有 Gene/Capsule,并在提示词中输出可审计的 Selector 决策 JSON。
359
360
  - **约束**:除 🧬 外,禁止使用其他 emoji。
360
361
 
361
362
  ### 升级不再覆盖你的本地资产库
362
363
 
363
- `assets/gep/genes.json`、`assets/gep/capsules.json`、`assets/gep/events.jsonl` 属于你本地运行时。从 1.78.3 起,npm 发行包不再包含这三个文件,`npm i -g @evomap/evolver`(或公共仓库的 `git pull`)不会再覆盖你累积的 Gene、Capsule 和 EvolutionEvent。新装用户依然会通过 `assets/gep/genes.seed.json` 拿到引擎维护的 starter Gene —— 只有在本地 `genes.json` 不存在时才会应用一次。
364
+ `<workspace>/.evolver/gep/genes.json`、`<workspace>/.evolver/gep/capsules.json`、`<workspace>/.evolver/gep/events.jsonl` 属于你本地运行时,并被 git 忽略。`assets/gep/` 保留给随包发布的 starter 资产。首次运行时,evolver 会把旧版遗留在 `assets/gep/` 的运行时文件复制到 `.evolver/gep/`,不会删除原文件;只有在本地 `genes.json` 不存在时,才会从随包 starter Gene 初始化。
364
365
 
365
366
  如果你之前用老版本被覆盖过,现在可以一键把所有被 Promoted 给你、以及你自己上传到 Hub 的资产拉回来:
366
367
 
@@ -370,7 +371,7 @@ A2A_HUB_URL=https://evomap.ai evolver sync --scope=all --export=backup.gepx
370
371
 
371
372
  它会去 `/a2a/assets/purchased`(被 Promoted 给你 + 自购)和 `/a2a/assets/published-by-me`(你自己发布的,含 draft)拉回完整 payload,直接回写 `genes.json` / `capsules.json`,并顺便打成 `.gepx` 整包备份。已购买过的 payload 这次重新拉取不收费。
372
373
 
373
- 纯本地、从未上传过的资产 Hub 没有副本,只能从 git 历史恢复(例如 `git show <老tag>:assets/gep/genes.json > restored.json`)或从磁盘快照找回。
374
+ 纯本地、从未上传过的资产 Hub 没有副本,只能从 `.evolver/gep/`、旧版 `assets/gep/` checkout 或磁盘快照找回。
374
375
 
375
376
  ## 配置与解耦
376
377
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.87.2",
3
+ "version": "1.87.3",
4
4
  "description": "A GEP-powered self-evolution engine for AI agents. Features automated log analysis and Genome Evolution Protocol (GEP) for auditable, reusable evolution assets.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -192,7 +192,14 @@ step('Stage 1 — bun bundle (resolve require tree to one file)');
192
192
  ensureDir(STAGE_DIR);
193
193
  const BUNDLED_JS = path.join(STAGE_DIR, 'bundled.js');
194
194
 
195
- run('bun', ['build', ENTRY, '--target=node', `--outfile=${BUNDLED_JS}`]);
195
+ // `--external '@napi-rs/keyring'`: keyring is an optional dep loaded via
196
+ // dynamic require() in workspace-id; bun otherwise tries to bundle the
197
+ // platform-specific `.node` file as a second output asset, which makes
198
+ // `bun build … --outfile=…` fail with "cannot write multiple output files
199
+ // without an output directory". Treating it as external preserves the
200
+ // existing optional-fallback behaviour (require throws → FS path used) in
201
+ // the standalone binaries.
202
+ run('bun', ['build', ENTRY, '--target=node', `--outfile=${BUNDLED_JS}`, '--external', '@napi-rs/keyring']);
196
203
 
197
204
  const bundleSize = OPTS.dryRun ? 0 : fs.statSync(BUNDLED_JS).size;
198
205
  console.log(` bundled.js: ${(bundleSize / 1024 / 1024).toFixed(2)} MB`);
@@ -266,13 +273,30 @@ if (!OPTS.skipObfuscate) {
266
273
  const obfSecs = ((Date.now() - t0) / 1000).toFixed(1);
267
274
 
268
275
  const check = spawnSync('node', ['--check', OBF_JS], { encoding: 'utf8' });
269
- if (check.status === 0) {
270
- console.log(` obfuscation: ${obfSecs}s, output ${(obfSize / 1024 / 1024).toFixed(2)} MB (attempt ${attempt}/${MAX_OBF_ATTEMPTS}, seed=0x${usedSeed.toString(16)})`);
271
- succeeded = true;
272
- break;
276
+ if (check.status !== 0) {
277
+ lastValidationErr = (check.stderr || check.stdout || '').split('\n').slice(0, 3).join(' | ');
278
+ console.warn(` attempt ${attempt}/${MAX_OBF_ATTEMPTS}: obfuscator output failed node --check (${lastValidationErr.slice(0, 200)}); retrying with perturbed seed...`);
279
+ continue;
273
280
  }
274
- lastValidationErr = (check.stderr || check.stdout || '').split('\n').slice(0, 3).join(' | ');
275
- console.warn(` attempt ${attempt}/${MAX_OBF_ATTEMPTS}: obfuscator output failed node --check (${lastValidationErr.slice(0, 200)}); retrying with perturbed seed...`);
281
+ // Second gate: bun's compile-time parser is stricter than node's.
282
+ // 1.87.x (post `@napi-rs/keyring` dep) revealed that ~5% of obfuscator
283
+ // outputs that pass `node --check` still trip bun with errors like
284
+ // `Expected "in" but found ","`. Probe with a cheap bundle-only call
285
+ // (no --compile, native target) to fail fast and feed back into the
286
+ // seed-perturbation loop instead of dying in stage 3.
287
+ const bunProbe = spawnSync('bun', [
288
+ 'build', OBF_JS,
289
+ '--target=bun',
290
+ `--outfile=${path.join(STAGE_DIR, 'bundled.obf.bunprobe.js')}`,
291
+ ], { encoding: 'utf8' });
292
+ if (bunProbe.status !== 0) {
293
+ lastValidationErr = (bunProbe.stderr || bunProbe.stdout || '').split('\n').slice(0, 3).join(' | ');
294
+ console.warn(` attempt ${attempt}/${MAX_OBF_ATTEMPTS}: obfuscator output rejected by bun parser (${lastValidationErr.slice(0, 200)}); retrying with perturbed seed...`);
295
+ continue;
296
+ }
297
+ console.log(` obfuscation: ${obfSecs}s, output ${(obfSize / 1024 / 1024).toFixed(2)} MB (attempt ${attempt}/${MAX_OBF_ATTEMPTS}, seed=0x${usedSeed.toString(16)})`);
298
+ succeeded = true;
299
+ break;
276
300
  }
277
301
  if (!succeeded) {
278
302
  console.error(` ERROR: javascript-obfuscator produced syntactically invalid output in ${MAX_OBF_ATTEMPTS} attempts.`);
@@ -27,6 +27,7 @@ const https = require('https');
27
27
  const crypto = require('crypto');
28
28
 
29
29
  const { computeAssetId } = require('../gep/contentHash');
30
+ const { enforceHubScheme, strictHttpsAgent } = require('../gep/hubFetch');
30
31
  const {
31
32
  getNodeId,
32
33
  getHubUrl,
@@ -114,6 +115,19 @@ function _publishUrl() {
114
115
 
115
116
  function _postJson(urlStr, body, timeoutMs) {
116
117
  return new Promise(function (resolve) {
118
+ // Same TLS posture as hubFetch: refuse plain http:// unless
119
+ // EVOMAP_HUB_ALLOW_INSECURE=1. Before this guard the function
120
+ // silently fell back to `lib = http` for any non-https URL, so an
121
+ // operator override `A2A_HUB_URL=http://...` would send /a2a/publish
122
+ // and /a2a/task/complete in cleartext while hubFetch-routed calls
123
+ // (e.g. /a2a/verify-solidify) refused the same URL — inconsistent
124
+ // TLS enforcement across modules.
125
+ try {
126
+ enforceHubScheme(urlStr);
127
+ } catch (e) {
128
+ resolve({ ok: false, error: 'tls_refused: ' + (e && e.message) });
129
+ return;
130
+ }
117
131
  let parsed;
118
132
  try {
119
133
  parsed = new URL(urlStr);
@@ -128,15 +142,28 @@ function _postJson(urlStr, body, timeoutMs) {
128
142
  { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
129
143
  buildHubHeaders() || {},
130
144
  );
145
+ // Pin TLS cert verification for https calls so a globally-disabled
146
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 cannot weaken the Hub channel
147
+ // (Cursor Security Reviewer #160 Medium). hubFetch enforces the
148
+ // same via its undici dispatcher; this is the Node-native-https
149
+ // equivalent.
150
+ //
151
+ // Skipped under EVOMAP_HUB_ALLOW_INSECURE=1 so local-dev / self-
152
+ // signed mock hubs that legitimately rely on
153
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 still work.
154
+ const requestOpts = {
155
+ hostname: parsed.hostname,
156
+ port: parsed.port || (isHttps ? 443 : 80),
157
+ path: parsed.pathname + (parsed.search || ''),
158
+ method: 'POST',
159
+ headers: headers,
160
+ timeout: timeoutMs || PUBLISH_TIMEOUT_MS,
161
+ };
162
+ if (isHttps && process.env.EVOMAP_HUB_ALLOW_INSECURE !== '1') {
163
+ requestOpts.agent = strictHttpsAgent;
164
+ }
131
165
  const req = lib.request(
132
- {
133
- hostname: parsed.hostname,
134
- port: parsed.port || (isHttps ? 443 : 80),
135
- path: parsed.pathname + (parsed.search || ''),
136
- method: 'POST',
137
- headers: headers,
138
- timeout: timeoutMs || PUBLISH_TIMEOUT_MS,
139
- },
166
+ requestOpts,
140
167
  function (res) {
141
168
  const chunks = [];
142
169
  res.on('data', function (c) { chunks.push(c); });
@@ -36,7 +36,13 @@ const DEFAULT_DAILY_CAP = 50;
36
36
  const DEFAULT_PER_ORDER_CAP = 10;
37
37
  const DEFAULT_ORDER_TIMEOUT_MS = 3000;
38
38
  const COLD_START_WINDOW_MS = 5 * 60 * 1000;
39
+ // Successful orders dedup for 24h so the same capability gap is only paid for
40
+ // once per day. Failed orders dedup for 5 minutes only — long enough to
41
+ // absorb tight retry loops (the original goal of "don't hammer the hub")
42
+ // without making the user wait 24h to retry a question after a transient
43
+ // 503/network blip.
39
44
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
45
+ const DEDUP_FAILURE_TTL_MS = 5 * 60 * 1000;
40
46
  const LEDGER_FILENAME = 'atp-autobuyer-ledger.json';
41
47
  const ACK_FILENAME = 'atp-autobuy-ack.json';
42
48
 
@@ -145,13 +151,22 @@ function _rotateIfNewDay(ledger, now) {
145
151
  }
146
152
 
147
153
  function _pruneDedup(ledger, now) {
148
- const cutoff = (typeof now === 'number' ? now : Date.now()) - DEDUP_TTL_MS;
154
+ const nowMs = typeof now === 'number' ? now : Date.now();
149
155
  const out = {};
150
156
  const src = ledger.dedup || {};
151
157
  const keys = Object.keys(src);
152
158
  for (let i = 0; i < keys.length; i++) {
153
159
  const k = keys[i];
154
- if (typeof src[k] === 'number' && src[k] >= cutoff) out[k] = src[k];
160
+ const entry = src[k];
161
+ // Legacy ledgers written by older versions stored plain timestamps; treat
162
+ // them as successful orders (the original behaviour) so an upgrade does
163
+ // not suddenly forget recent dedups.
164
+ if (typeof entry === 'number') {
165
+ if (entry >= nowMs - DEDUP_TTL_MS) out[k] = entry;
166
+ } else if (entry && typeof entry.ts === 'number') {
167
+ const ttl = entry.failed ? DEDUP_FAILURE_TTL_MS : DEDUP_TTL_MS;
168
+ if (entry.ts >= nowMs - ttl) out[k] = entry;
169
+ }
155
170
  }
156
171
  ledger.dedup = out;
157
172
  return ledger;
@@ -205,11 +220,36 @@ function _withTimeout(promise, timeoutMs) {
205
220
  ]);
206
221
  }
207
222
 
208
- async function considerOrder(opts) {
209
- if (!_started) return { ok: false, skipped: true, reason: 'not_started' };
223
+ // Single-flight queue: serialize the read → cap-check → placeOrder → write
224
+ // pipeline so two concurrent considerOrder() calls cannot both pass the cap
225
+ // check on the same ledger snapshot and silently double-spend.
226
+ //
227
+ // Without this, two parallel calls (e.g. user runs Claude Code in two tabs
228
+ // through the same proxy, or two capability gaps fire in the same tick) both
229
+ // read spent=40, both compute remaining=10, both await placeOrder, both
230
+ // increment to spent=50, and write — silently exceeding the daily cap by one
231
+ // full order each. autoBuyer is single-process so an in-memory queue is
232
+ // sufficient; a file lock would only be needed if multiple OS processes
233
+ // shared the same ledger file (not the current deployment model).
234
+ let _orderQueue = Promise.resolve();
235
+
236
+ function considerOrder(opts) {
237
+ if (!_started) return Promise.resolve({ ok: false, skipped: true, reason: 'not_started' });
210
238
  if (!opts || !Array.isArray(opts.capabilities) || opts.capabilities.length === 0) {
211
- return { ok: false, skipped: true, reason: 'no_capabilities' };
239
+ return Promise.resolve({ ok: false, skipped: true, reason: 'no_capabilities' });
212
240
  }
241
+ const next = _orderQueue.then(
242
+ () => _considerOrderSerialized(opts),
243
+ () => _considerOrderSerialized(opts), // never let a prior rejection break the chain
244
+ );
245
+ // Swallow rejection on the queue tail so a single thrown error here does
246
+ // not poison every subsequent call; the original `next` promise still
247
+ // surfaces the error to the caller.
248
+ _orderQueue = next.then(() => {}, () => {});
249
+ return next;
250
+ }
251
+
252
+ async function _considerOrderSerialized(opts) {
213
253
  const now = Date.now();
214
254
  let ledger = _readLedger();
215
255
  ledger = _rotateIfNewDay(ledger, now);
@@ -248,15 +288,17 @@ async function considerOrder(opts) {
248
288
 
249
289
  if (result && result.ok) {
250
290
  ledger.spent = (ledger.spent || 0) + budget;
251
- ledger.dedup[hash] = now;
291
+ ledger.dedup[hash] = { ts: now, failed: false };
252
292
  _writeLedger(ledger);
253
293
  console.log('[ATP-AutoBuyer] Order placed: ' + (result.data && result.data.order_id) + ' budget=' + budget + ' remaining_today=' + Math.max(0, dailyCap - ledger.spent));
254
294
  return { ok: true, data: result.data, spent: budget };
255
295
  }
256
296
 
257
- // On failure still record dedup so we don't hammer the hub for the same
258
- // capability gap within the TTL window (but do NOT charge the spend).
259
- ledger.dedup[hash] = now;
297
+ // On failure record a SHORT-TTL dedup entry (5 min) so we don't hammer the
298
+ // hub for the same capability gap inside a tight retry loop, but the user
299
+ // can retry the same question once the transient error clears — far better
300
+ // than the previous 24h block for a single 503.
301
+ ledger.dedup[hash] = { ts: now, failed: true };
260
302
  _writeLedger(ledger);
261
303
  return { ok: false, error: (result && result.error) || 'unknown_error' };
262
304
  }
@@ -267,15 +309,25 @@ async function considerOrder(opts) {
267
309
  // via .tmp + rename so a crash mid-write never produces a corrupt ack file.
268
310
  function setConsent(enabled) {
269
311
  const dir = getMemoryDir();
270
- try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch (_) {}
271
312
  const body = {
272
313
  enabled: !!enabled,
273
314
  acknowledged_at: new Date().toISOString(),
274
315
  version: 1,
275
316
  };
276
317
  const tmp = _ackPath() + '.tmp';
277
- fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8');
278
- fs.renameSync(tmp, _ackPath());
318
+ // Single try/catch over the whole pipeline. Previously the mkdirSync was
319
+ // wrapped in its own swallowing try/catch, so an EACCES on the parent dir
320
+ // would surface to the caller as a confusing ENOENT from writeFileSync.
321
+ // Surface the original error verbatim and best-effort clean up any
322
+ // partial .tmp file so a retry from a TTY prompt sees a clean slate.
323
+ try {
324
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
325
+ fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8');
326
+ fs.renameSync(tmp, _ackPath());
327
+ } catch (err) {
328
+ try { fs.unlinkSync(tmp); } catch (_) {}
329
+ throw err;
330
+ }
279
331
  return body;
280
332
  }
281
333
 
@@ -288,6 +340,7 @@ function _resetForTests() {
288
340
  perOrderCap: DEFAULT_PER_ORDER_CAP,
289
341
  timeoutMs: DEFAULT_ORDER_TIMEOUT_MS,
290
342
  };
343
+ _orderQueue = Promise.resolve();
291
344
  }
292
345
 
293
346
  module.exports = {
@@ -306,22 +359,24 @@ module.exports = {
306
359
  readAck: _readAck,
307
360
  ACK_FILENAME,
308
361
  // Exposed for tests and diagnostics only; callers should not depend on
309
- // these internals in production code paths.
362
+ // these internals in production code paths. Ack-file helpers
363
+ // (getAckPath / readAck / ACK_FILENAME) are intentionally NOT mirrored
364
+ // here — production and tests both go through the public surface above,
365
+ // so a single source of truth survives schema changes (Bugbot PR #141 R6
366
+ // follow-up: keep "test-only" honest, no production caller may reach in).
310
367
  __internals: {
311
368
  readLedger: _readLedger,
312
369
  writeLedger: _writeLedger,
313
370
  questionHash: _questionHash,
314
371
  effectiveCap: _effectiveCap,
315
372
  resetForTests: _resetForTests,
316
- ackPath: _ackPath,
317
- readAck: _readAck,
318
373
  constants: {
319
374
  DEFAULT_DAILY_CAP,
320
375
  DEFAULT_PER_ORDER_CAP,
321
376
  COLD_START_WINDOW_MS,
322
377
  DEDUP_TTL_MS,
378
+ DEDUP_FAILURE_TTL_MS,
323
379
  LEDGER_FILENAME,
324
- ACK_FILENAME,
325
380
  },
326
381
  },
327
382
  };
@@ -155,6 +155,18 @@ function start(opts) {
155
155
  _pollInterval = setInterval(function () {
156
156
  _tick().catch(function () { /* swallowed in _tick */ });
157
157
  }, _pollMs);
158
+ // .unref() so this background poller does NOT keep the Node event
159
+ // loop alive on its own. `evolver run` (single-shot) writes its
160
+ // artifacts and expects to exit — without unref, the setInterval
161
+ // handle pins the process and the run sits as a residual `node`
162
+ // process until manually killed (public issue #553). `evolver --loop`
163
+ // (daemon) keeps the foreground evolve loop alive on its own
164
+ // schedule, so an unref'd poller still polls — unref only changes
165
+ // whether THIS handle alone keeps the loop alive, not whether the
166
+ // handle fires.
167
+ if (_pollInterval && typeof _pollInterval.unref === 'function') {
168
+ _pollInterval.unref();
169
+ }
158
170
  // Do not await -- fire the first tick asynchronously so start() returns
159
171
  // immediately. This matches the autoBuyer start() semantics.
160
172
  _tick().catch(function () { /* swallowed in _tick */ });
@@ -189,6 +201,10 @@ module.exports = {
189
201
  writeLedger: _writeLedger,
190
202
  buildProofPayload: _buildProofPayload,
191
203
  resetForTests: _resetForTests,
204
+ // Test-only accessor for the active poll Timeout. Used to assert
205
+ // the timer was `.unref()`ed so it does not pin the Node event
206
+ // loop (regression guard for public issue #553).
207
+ getPollIntervalForTest: () => _pollInterval,
192
208
  constants: {
193
209
  DEFAULT_POLL_MS,
194
210
  MIN_POLL_MS,
@@ -22,22 +22,13 @@
22
22
  const readline = require("readline");
23
23
  const autoBuyer = require("./autoBuyer");
24
24
 
25
- // All ack file plumbing is owned by autoBuyer: filename constant, path
26
- // resolution, read (strict validation), and write (atomic tmp+rename).
27
- // cliAutobuyPrompt delegates through the public API (not __internals) so
28
- // the two modules cannot diverge on schema or validation — pre-
29
- // consolidation drift bit us twice (Bugbot PR #141: duplicate writers +
30
- // lenient-vs-strict reader). Using public exports keeps the "test-only"
31
- // contract on __internals honest (Bugbot PR #141 R6).
32
- const ACK_FILE_NAME = autoBuyer.ACK_FILENAME;
33
-
34
- function _getAckPath() {
35
- return autoBuyer.getAckPath();
36
- }
37
-
38
- function _readAck() {
39
- return autoBuyer.readAck();
40
- }
25
+ // All ack file plumbing lives on autoBuyer (filename constant, path
26
+ // resolution, read with strict validation, atomic write via tmp+rename).
27
+ // cliAutobuyPrompt always reaches it through the public surface so the
28
+ // two modules cannot diverge on schema or validation — pre-consolidation
29
+ // drift bit us twice (Bugbot PR #141: duplicate writers + lenient-vs-
30
+ // strict reader). No __internals re-export here either: tests import
31
+ // autoBuyer directly so a future rename trips a single set of asserts.
41
32
 
42
33
  /**
43
34
  * @returns {"ack_present"|"env_set"|"non_tty"|"eligible"}
@@ -50,7 +41,7 @@ function classify(env, stdin) {
50
41
  if (!stdin || !stdin.isTTY) {
51
42
  return "non_tty";
52
43
  }
53
- if (_readAck()) {
44
+ if (autoBuyer.readAck()) {
54
45
  return "ack_present";
55
46
  }
56
47
  return "eligible";
@@ -160,9 +151,4 @@ async function runPrompt(opts) {
160
151
  module.exports = {
161
152
  runPrompt,
162
153
  classify,
163
- __internals: {
164
- ACK_FILE_NAME,
165
- _readAck,
166
- _getAckPath,
167
- },
168
154
  };
@@ -16,6 +16,7 @@
16
16
 
17
17
  const http = require('http');
18
18
  const { getHubUrl, buildHubHeaders, getNodeId } = require('../gep/a2aProtocol');
19
+ const { hubFetch } = require('../gep/hubFetch');
19
20
  const { getProxyUrl, getProxyToken } = require('../proxy/server/settings');
20
21
 
21
22
  function _isProxyMode() {
@@ -68,12 +69,33 @@ function _proxyRequest(method, path, body, timeoutMs) {
68
69
  });
69
70
  }
70
71
 
72
+ // Route through hubFetch() rather than the global `fetch()` for two
73
+ // reasons (both flagged by Cursor reviewers on PR #160):
74
+ //
75
+ // 1. Dispatcher mixing (Bugbot HIGH): `strictUndiciAgent` is an Agent
76
+ // from the *installed* `undici` package, but `global.fetch` is
77
+ // backed by Node's *internal* undici. Passing one to the other
78
+ // throws `UND_ERR_INVALID_ARG: invalid onRequestStart method` at
79
+ // request time — exactly the failure mode the comment at the top
80
+ // of hubFetch.js calls out. hubFetch already routes through
81
+ // `undici.fetch` from the same package as its Agent, so all calls
82
+ // that go through hubFetch are immune.
83
+ //
84
+ // 2. Case-sensitive scheme check (Security Reviewer MEDIUM): a hand-
85
+ // rolled `endpoint.startsWith('https:')` would skip the strict
86
+ // dispatcher for `HTTPS://...`. hubFetch's `_validateHubUrl` uses
87
+ // `new URL(url).protocol`, which normalises to lowercase, so
88
+ // routing through it eliminates the bug class.
89
+ //
90
+ // Routing through hubFetch also inherits the URL-scheme enforcement and
91
+ // the EVOMAP_HUB_ALLOW_INSECURE escape hatch automatically; we no
92
+ // longer need the explicit `enforceHubScheme` guard here.
71
93
  function _hubPost(pathSuffix, body, timeoutMs) {
72
94
  const hubUrl = getHubUrl();
73
95
  if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
74
96
  const endpoint = hubUrl.replace(/\/+$/, '') + pathSuffix;
75
97
  const timeout = timeoutMs || require('../config').HTTP_TRANSPORT_TIMEOUT_MS;
76
- return fetch(endpoint, {
98
+ return hubFetch(endpoint, {
77
99
  method: 'POST',
78
100
  headers: buildHubHeaders(),
79
101
  body: JSON.stringify(body),
@@ -83,7 +105,17 @@ function _hubPost(pathSuffix, body, timeoutMs) {
83
105
  if (!res.ok) return res.text().then(function (t) { return { ok: false, status: res.status, error: t.slice(0, 400) }; });
84
106
  return res.json().then(function (data) { return { ok: true, data: data }; });
85
107
  })
86
- .catch(function (err) { return { ok: false, error: err.message }; });
108
+ .catch(function (err) {
109
+ // hubFetch throws synchronously (rejected Promise) when the URL
110
+ // fails scheme validation in secure mode. Translate to the same
111
+ // structured envelope the previous in-line guard produced so the
112
+ // caller contract is unchanged.
113
+ const msg = (err && err.message) || String(err);
114
+ if (msg.indexOf('[hubFetch]') !== -1) {
115
+ return { ok: false, error: 'tls_refused: ' + msg };
116
+ }
117
+ return { ok: false, error: msg };
118
+ });
87
119
  }
88
120
 
89
121
  function _hubGet(pathSuffix, timeoutMs) {
@@ -91,7 +123,7 @@ function _hubGet(pathSuffix, timeoutMs) {
91
123
  if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
92
124
  const endpoint = hubUrl.replace(/\/+$/, '') + pathSuffix;
93
125
  const timeout = timeoutMs || require('../config').HTTP_TRANSPORT_TIMEOUT_MS;
94
- return fetch(endpoint, {
126
+ return hubFetch(endpoint, {
95
127
  method: 'GET',
96
128
  headers: buildHubHeaders(),
97
129
  signal: AbortSignal.timeout(timeout),
@@ -100,7 +132,13 @@ function _hubGet(pathSuffix, timeoutMs) {
100
132
  if (!res.ok) return res.text().then(function (t) { return { ok: false, status: res.status, error: t.slice(0, 400) }; });
101
133
  return res.json().then(function (data) { return { ok: true, data: data }; });
102
134
  })
103
- .catch(function (err) { return { ok: false, error: err.message }; });
135
+ .catch(function (err) {
136
+ const msg = (err && err.message) || String(err);
137
+ if (msg.indexOf('[hubFetch]') !== -1) {
138
+ return { ok: false, error: 'tls_refused: ' + msg };
139
+ }
140
+ return { ok: false, error: msg };
141
+ });
104
142
  }
105
143
 
106
144
  // Dispatcher: choose proxy or direct hub based on env + proxy availability.